Educational Content: This article is written for educational purposes to help developers and cybersecurity students understand software concepts. Always follow ethical guidelines and applicable laws.
I want to start with something a bit uncomfortable: most PHP APIs that developers ship to production have at least one serious security flaw in them. Not because developers are careless — but because PHP makes it genuinely easy to build something that works perfectly and leaks data at the same time.
I've looked at a lot of codebases over the years. Freelance projects, open-source panels, client work handed off by other developers. The same mistakes show up over and over again. This post is a walkthrough of the most common ones, with actual code examples so you can go check your own API right now.
The Problem With "It Works" Thinking
When you're building an API, the main goal is usually to get data from point A to point B. You test the endpoint, data comes back correctly, no errors in the console — shipped. The issue is that "it works" says nothing about what an attacker can do with it.
PHP especially rewards this kind of thinking because the language handles a lot of things silently. Type juggling, loose comparisons, implicit conversions — PHP smooths over a lot of rough edges, which is great for rapid development and quietly terrible for security.
Let's get into the specific problems.
1. Returning Too Much Data From Database Queries
This is probably the most widespread issue I see. A developer writes something like this:
$user = $db->query("SELECT * FROM users WHERE id = $id")->fetch();
echo json_encode($user);
That SELECT * returns everything — including password hashes, internal flags, reset tokens, admin status fields, and whatever else is in that row. The frontend might only display the username and email, but every field is sitting in that API response, readable by anyone who opens their browser's developer tools.
The fix is boring but important: always explicitly select the columns you need.
$stmt = $db->prepare("SELECT id, username, email, created_at FROM users WHERE id = ?");
$stmt->execute([$id]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
echo json_encode($user);
This also has the side effect of making your API more predictable. When you add a column to a table later, it won't silently start appearing in API responses.
2. Missing or Broken Authentication Checks
Here's a pattern I've seen more times than I'd like:
if($_GET['token'] == $stored_token) {
// show private data
}
Two problems here. First, that double equals sign. PHP's loose comparison means that 0 == "any_string" evaluates to true in older PHP versions. If your token somehow ends up being compared to a numeric zero, you've got a bypass. Always use === for security comparisons.
Second — and this is the bigger issue — tokens passed as GET parameters show up in server logs, browser history, and referrer headers. Someone who gets access to your logs gets access to every token that ever passed through.
Tokens belong in the Authorization header:
$headers = getallheaders();
$token = $headers['Authorization'] ?? '';
$token = str_replace('Bearer ', '', $token);
if (!hash_equals($stored_token, $token)) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
The hash_equals() function is important here — it prevents timing attacks where an attacker can figure out how many characters of a token are correct based on how long the comparison takes.
3. No Rate Limiting on Sensitive Endpoints
If your login endpoint, OTP verification, or password reset flow has no rate limiting, it's open to brute force by default. An attacker can just write a loop and hammer the endpoint until something works.
A basic rate limiter in PHP using a database or Redis looks like this:
function checkRateLimit($identifier, $maxAttempts = 5, $windowSeconds = 60) {
global $db;
$stmt = $db->prepare(
"SELECT attempts, window_start FROM rate_limits
WHERE identifier = ? AND window_start > NOW() - INTERVAL ? SECOND"
);
$stmt->execute([$identifier, $windowSeconds]);
$row = $stmt->fetch();
if ($row && $row['attempts'] >= $maxAttempts) {
return false; // blocked
}
// upsert attempt count
$db->prepare(
"INSERT INTO rate_limits (identifier, attempts, window_start)
VALUES (?, 1, NOW())
ON DUPLICATE KEY UPDATE attempts = attempts + 1"
)->execute([$identifier]);
return true;
}
$ip = $_SERVER['REMOTE_ADDR'];
if (!checkRateLimit('login_' . $ip)) {
http_response_code(429);
echo json_encode(['error' => 'Too many attempts. Try again later.']);
exit;
}
This is a simplified version — in production you'd also want to rate limit by username separately so attackers can't bypass it by rotating IPs, and you'd use Redis for performance. But even this basic version stops the vast majority of automated attacks.
4. Error Messages That Tell Attackers Too Much
PHP's default error output is incredibly helpful during development and a gift to attackers in production.
A database error that says "SQLSTATE[42S02]: Table 'jshook_db.usr' doesn't exist" tells an attacker your database name, that you're using MySQL, and that there's a table called usr (probably a typo of users). That's useful reconnaissance information.
Your PHP configuration for production should have:
display_errors = Off
log_errors = On
error_log = /path/to/your/error.log
And your API error responses should be generic:
try {
// database operation
} catch (Exception $e) {
error_log($e->getMessage()); // log the real error privately
http_response_code(500);
echo json_encode(['error' => 'Something went wrong. Please try again.']);
exit;
}
Log the full details privately. Show the user (and any potential attacker) as little as possible.
5. CORS Configured to Allow Everything
Cross-Origin Resource Sharing (CORS) controls which domains can make requests to your API from a browser. A common "fix" for CORS errors during development is to add this header:
header('Access-Control-Allow-Origin: *');
That wildcard means any website on the internet can make authenticated requests to your API from a user's browser — including malicious sites. If your API uses cookies for authentication, an attacker could build a page that silently makes requests to your API on behalf of anyone who visits it.
In production, whitelist exactly the domains that need access:
$allowed_origins = ['https://jshook.online', 'https://www.jshook.online'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed_origins)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
}
6. SQL Injection Through "Safe-Looking" Input
Most developers know about SQL injection at this point, but it still gets through in subtle ways. The dangerous assumption is that some input is "safe" because it was validated earlier or comes from a dropdown.
The rule is simple: every single value that touches a SQL query goes through a prepared statement, no exceptions.
// dangerous - even if $order_column was validated earlier in the code
$query = "SELECT * FROM orders ORDER BY $order_column";
// safer approach for dynamic column names
$allowed_columns = ['created_at', 'total', 'status'];
if (!in_array($order_column, $allowed_columns, true)) {
$order_column = 'created_at'; // default safe value
}
$query = "SELECT * FROM orders ORDER BY $order_column"; // now safe
Note that prepared statements can't parameterize column names or table names — only values. That's why for dynamic column sorting, you need a whitelist approach like the above.
Putting It Together: A Secure API Endpoint Template
Here's what a reasonably secure basic PHP API endpoint looks like when you apply all of the above:
<?php
header('Content-Type: application/json');
// 1. Rate limiting
require 'includes/rate_limiter.php';
if (!checkRateLimit('api_' . $_SERVER['REMOTE_ADDR'])) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded']);
exit;
}
// 2. Authentication
require 'includes/auth.php';
$user = authenticateRequest(); // validates Bearer token, returns user or null
if (!$user) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// 3. Input validation
$requested_id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!$requested_id) {
http_response_code(400);
echo json_encode(['error' => 'Invalid request']);
exit;
}
// 4. Database query with explicit columns
try {
require 'includes/db.php';
$stmt = $db->prepare("SELECT id, name, email, created_at FROM users WHERE id = ?");
$stmt->execute([$requested_id]);
$data = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$data) {
http_response_code(404);
echo json_encode(['error' => 'Not found']);
exit;
}
echo json_encode(['success' => true, 'data' => $data]);
} catch (Exception $e) {
error_log('[API Error] ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Server error']);
}
It's not glamorous. It's mostly just discipline — checking things in the right order, being explicit about what you allow, and failing loudly in your logs while failing quietly to the outside world.
Final Thought
Security doesn't have to be a separate phase of development. The habits above — prepared statements, explicit column selection, header-based auth, generic error messages — don't take significantly longer to write once they become instinctive.
The goal isn't to build an impenetrable system. It's to make your application a more annoying target than the next one. Most attacks are opportunistic. Automated scanners hit millions of endpoints looking for easy wins. If yours doesn't respond to the easy attacks, they move on.
We'll be going deeper on specific parts of this — including a full secure authentication system in PHP and how to set up proper logging that actually tells you when something suspicious is happening. Drop a comment or reach out if there's something specific you'd like us to cover.
— JSHook Team