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. Not because developers are careless — but because PHP makes it genuinely easy to build something that works correctly and leaks data simultaneously. I've reviewed a lot of codebases over the years — freelance projects, open-source reseller panels, client work handed off by previous developers. The same mistakes appear consistently, and they're almost never exotic vulnerabilities. They're basics that got skipped during a deadline push.
This post walks through the most common issues with actual PHP code so you can audit your own API today — not someday.
Why "It Works" Is Not a Security Standard
When you're building an API, the primary goal is getting data from A to B correctly. You test the endpoint, data returns as expected, no errors in the console — shipped. The problem is that "it works" says nothing about what an attacker can extract from it. PHP specifically encourages this thinking because the language handles many edge cases silently. Type juggling, loose comparisons, implicit type coercions — PHP smooths over rough edges, which accelerates development and quietly enables entire vulnerability classes that developers don't know to look for.
1. Returning Everything From Database Queries
This is likely the most widespread issue in PHP APIs, and it's invisible during testing because the application functions correctly. A developer writes something like this:
$user = $db->query("SELECT * FROM users WHERE id = $id")->fetch();
echo json_encode($user);
That SELECT * returns every column in the row — including password hashes, internal boolean flags, email verification tokens, password reset tokens, admin status, and any other fields you've added over time. The frontend might display only the username and email, but the entire row is sitting in the API response, fully readable to anyone who opens DevTools and looks at the network tab.
I've found password reset tokens, unhashed secondary verification codes, and internal billing flags exposed this way in real production APIs. The information doesn't need to be immediately exploitable to be dangerous — it's reconnaissance that makes follow-up attacks easier and more targeted.
// Always explicitly select what 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 makes your API more predictable and maintainable. When you add a column to the table later, it won't silently start leaking in responses. Explicit is always better than implicit in API design.
2. Loose Comparisons in Authentication Logic
Here's a pattern I've seen in dozens of PHP authentication implementations:
if($_GET['token'] == $stored_token) {
// grant access
}
Two issues, both serious. First, the double equals sign. PHP's loose type comparison means that in older PHP versions, 0 == "any_string" evaluates to true. If your stored token is somehow compared against a numeric zero, you have an authentication bypass. This has been exploited in real frameworks. Second, tokens passed as GET parameters appear in server logs, browser history, CDN logs, and referrer headers. Any system that touches that URL can read the token. Tokens belong in request headers, not URLs.
$headers = getallheaders();
$token = $headers['Authorization'] ?? '';
$token = str_replace('Bearer ', '', $token);
// hash_equals prevents timing attacks
if (!hash_equals($stored_token, $token)) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
The hash_equals() function is critical here — it prevents timing attacks where an attacker can determine how many characters of a token are correct based on the microsecond differences in how long the comparison takes. Regular string comparison (===) short-circuits on the first mismatch, which leaks timing information.
3. No Rate Limiting on Authentication Endpoints
If your login endpoint, OTP verification, or password reset flow has no rate limiting, it's open to automated brute force by default. An attacker runs a loop sending thousands of requests per minute. No fancy tooling needed — a five-line Python script will do it.
The fix doesn't need to be complex to be effective. A basic rate limiter using your database:
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
}
$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. Please wait and try again.']);
exit;
}
In production, also rate limit by username separately — so attackers can't bypass IP limits by rotating proxies while still brute-forcing a specific account. For high-traffic APIs, Redis is significantly more performant than MySQL for rate limiting.
4. Error Messages That Provide Reconnaissance
PHP's default error output is invaluable during development and a free intelligence briefing for attackers in production. A database error like SQLSTATE[42S02]: Table 'jshook_db.usr' doesn't exist tells an attacker your database name (jshook_db), your database engine (MySQL), and that there's probably a table called users — useful for targeted SQL injection attempts.
Your production PHP configuration should always have:
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
And your error handling in API code:
try {
// database operation
} catch (PDOException $e) {
error_log('[DB Error] ' . $e->getMessage() . ' | Trace: ' . $e->getTraceAsString());
http_response_code(500);
echo json_encode(['error' => 'An unexpected error occurred. Please try again.']);
exit;
} catch (Exception $e) {
error_log('[App Error] ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Something went wrong.']);
exit;
}
Log the full details privately — including stack traces and query details. Show the outside world nothing useful. This principle applies to validation errors too: don't confirm whether a specific username exists or whether a specific email is registered. Generic error messages aren't just good security — they prevent enumeration attacks on your user database.
5. CORS Configured With a Wildcard
The fastest way to "fix" a CORS error during development is to add Access-Control-Allow-Origin: *. This is a common development shortcut that frequently makes it to production and stays there because nothing is visibly broken — until something goes wrong.
The wildcard means any website on the internet can make cross-origin requests to your API from a user's browser. If your API uses cookie-based sessions, an attacker can build a malicious page that silently makes authenticated API calls on behalf of anyone who visits it — reading their data, performing actions as them, exfiltrating information. This is a browser-based CSRF variant that CORS is specifically designed to prevent when configured correctly.
$allowed_origins = [
'https://jshook.online',
'https://www.jshook.online',
'https://app.jshook.online'
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed_origins, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Vary: Origin');
header('Access-Control-Allow-Credentials: true');
}
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With');
The Vary: Origin header is important — it tells caches that the response varies based on the Origin header, preventing incorrect cached responses from being served to other origins.
6. Dynamic SQL Identifiers Bypassing Prepared Statements
Most developers know about SQL injection and use prepared statements. What trips people up is that prepared statements can only parameterize values — not column names, table names, or SQL keywords. Dynamic SQL identifiers need whitelist validation.
// Dangerous - even if $sort_column came from a dropdown in your UI
$query = "SELECT * FROM orders ORDER BY $sort_column $sort_direction";
// Correct approach
$allowed_columns = ['created_at', 'total_amount', 'status', 'customer_name'];
$allowed_directions = ['ASC', 'DESC'];
if (!in_array($sort_column, $allowed_columns, true)) {
$sort_column = 'created_at'; // safe default
}
if (!in_array(strtoupper($sort_direction), $allowed_directions, true)) {
$sort_direction = 'DESC'; // safe default
}
$stmt = $db->prepare(
"SELECT id, order_number, total_amount, status, created_at
FROM orders
WHERE user_id = ?
ORDER BY $sort_column $sort_direction"
);
$stmt->execute([$_SESSION['user_id']]);
The dropdown in your UI doesn't make the value safe — an attacker sends raw HTTP requests, not form submissions through your UI. Always validate against a whitelist on the server side, even for values you think could only come from controlled inputs.
7. Missing Authorization Checks (Not Just Authentication)
There's a critical difference between authentication (confirming who a user is) and authorization (confirming what they're allowed to access). Many PHP APIs correctly authenticate every request but don't verify that the authenticated user actually has rights to the specific resource they're requesting.
// Authenticated but missing authorization
$order_id = $_GET['order_id'];
$stmt = $db->prepare("SELECT * FROM orders WHERE id = ?");
$stmt->execute([$order_id]);
$order = $stmt->fetch();
echo json_encode($order); // returns ANY order to ANY authenticated user
An authenticated user changes order_id=1042 to order_id=1043 in the request and receives someone else's complete order details. This is called an Insecure Direct Object Reference (IDOR) and it's one of the most commonly found vulnerabilities in bug bounty programs because it's easy to miss and easy to exploit.
// Always tie resources to the authenticated user
$order_id = filter_input(INPUT_GET, 'order_id', FILTER_VALIDATE_INT);
if (!$order_id) {
http_response_code(400);
echo json_encode(['error' => 'Invalid request']);
exit;
}
$stmt = $db->prepare("SELECT * FROM orders WHERE id = ? AND user_id = ?");
$stmt->execute([$order_id, $_SESSION['user_id']]);
$order = $stmt->fetch();
if (!$order) {
http_response_code(404); // not 403 - don't confirm it exists
echo json_encode(['error' => 'Order not found']);
exit;
}
echo json_encode($order);
Return 404 rather than 403 for resources the user isn't authorized to access — this doesn't confirm to an attacker that the resource exists.
A Secure PHP API Endpoint Template
Here's what a properly structured basic PHP API endpoint looks like when you apply all of the above consistently:
<?php
header('Content-Type: application/json');
// Rate limiting
require_once 'includes/rate_limiter.php';
if (!checkRateLimit('api_' . $_SERVER['REMOTE_ADDR'])) {
http_response_code(429);
echo json_encode(['error' => 'Rate limit exceeded. Try again shortly.']);
exit;
}
// Authentication
require_once 'includes/auth.php';
$user = authenticateRequest(); // validates Bearer token, returns user array or null
if (!$user) {
http_response_code(401);
echo json_encode(['error' => 'Authentication required']);
exit;
}
// Input validation
$resource_id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!$resource_id || $resource_id <= 0) {
http_response_code(400);
echo json_encode(['error' => 'Invalid resource ID']);
exit;
}
// Database query with authorization and explicit columns
try {
require_once 'includes/db.php';
$stmt = $db->prepare(
"SELECT id, name, description, created_at
FROM resources
WHERE id = ? AND owner_id = ?"
);
$stmt->execute([$resource_id, $user['id']]);
$resource = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$resource) {
http_response_code(404);
echo json_encode(['error' => 'Not found']);
exit;
}
echo json_encode(['success' => true, 'data' => $resource]);
} catch (PDOException $e) {
error_log('[API Error] ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Server error. Please try again.']);
}
None of this is glamorous code. It's mostly discipline — checking things in the right order, being explicit about what's allowed, and making sure your application fails loudly in logs while saying as little as possible to the outside world.
Putting It Into Practice
Security doesn't have to be a separate development phase added at the end. These habits — prepared statements, explicit column selection, Bearer token auth, generic error messages, ownership checks on every resource query — don't take significantly longer once they become instinctive. The cost of adding them is measured in minutes. The cost of missing them can be a data breach that affects real people.
Go through your existing API endpoints today with this list. The goal isn't finding zero issues — almost no API passes a first audit. The goal is finding them yourself before someone else does, and building the habit of checking automatically on the next one you write.
— Skand K.