You've moved past the basics. Here's the layer of security mistakes that still ships to production — written by experienced developers who should've known better.
Let's be honest with each other.
You're not here to read about SQL injection. You've known about prepared statements since your second year. You hash passwords, you validate inputs, you've got HTTPS everywhere.
And yet — bugs still slip through. Not beginner bugs. The subtle ones. The ones that look correct in a code review. The ones that only break under specific conditions, or only matter when someone is actively trying to exploit them.
I've audited a lot of PHP codebases over the years. The six mistakes below came from developers with real experience — people who write clean, thoughtful code. That's precisely why they're dangerous.
1. Race Conditions in Business-Critical Logic
This is the one that costs real money.
The pattern is always the same: check a condition, then act on it. Looks completely correct in isolation. Breaks silently under concurrency.
A coupon system. A referral bonus. A flash sale with limited stock. The logic reads: "check if already used, if not — apply it."
// Both requests pass this check before either one writes
if (!$coupon->is_used) {
$order->applyDiscount($coupon->value);
$coupon->update(['is_used' => true]);
}Two requests arrive within milliseconds — a double-click, a retry, a race between two tabs. Both read is_used = false. Both apply the discount. The write happens twice.
// The fix — acquire a row-level lock before checking
DB::transaction(function () use ($coupon, $order) {
$coupon = Coupon::lockForUpdate()->findOrFail($coupon->id);
if ($coupon->is_used) return;
$order->applyDiscount($coupon->value);
$coupon->update(['is_used' => true]);
});lockForUpdate() blocks any other transaction from reading that row until this one commits. The second request then sees is_used = true and exits cleanly.
This isn't a beginner oversight. Senior developers write check-then-act logic every day — it's a natural way to think. The mistake is forgetting that your server handles multiple requests simultaneously, and your logic isn't as sequential as it reads.
2. JWT Algorithm Confusion
You implemented JWT authentication. You chose RS256 — asymmetric, private key signs, public key verifies. Solid choice.
Here's the attack: your public key is, by definition, public. An attacker takes it, changes the alg header in a token from RS256 to HS256, then signs the token using your public key as the HMAC secret.
A library that trusts the token's stated algorithm will now verify that forged token — using your own public key against you — and let it pass.
// Wrong — the token tells you how to verify it
$decoded = JWT::decode($token, $publicKey);
// Right — you tell the library what algorithm to expect
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));Always pin the algorithm on your side. The token should never decide how it gets verified.
While you're at it — are you validating exp? Are you checking iss and aud if you have multiple services? A token issued for your public API probably shouldn't be accepted by your internal admin panel. These feel like edge cases until someone exploits them.
3. Overly Broad CORS Configuration
Here's what makes this one so common among senior developers: it's usually not laziness. It's a deliberate decision made under pressure.
A mobile team is blocked. A third-party integration isn't working. Someone opens a ticket. You're busy. You add Access-Control-Allow-Origin: * to unblock everyone and plan to tighten it later. Later never comes. It ships.
That wildcard tells every browser on the planet: any website can make requests to this API and read the response. Combined with Access-Control-Allow-Credentials: true, it becomes a serious problem — because now a malicious site can make authenticated requests on behalf of your logged-in users and read the results.
Except — and this is the catch many developers miss — browsers actually block this combination. The spec forbids * with credentials. So some developers, trying to "fix" the blocked requests, switch to reflecting the Origin header back blindly:
// Looks dynamic and smart — actually worse than the wildcard
header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']);
header('Access-Control-Allow-Credentials: true');Now any origin is explicitly trusted. The browser restriction is gone. A malicious site can make a credentialed cross-origin request to your API and read the response. Full stop.
// The right way — maintain an explicit allowlist
$allowedOrigins = [
'https://yourapp.com',
'https://admin.yourapp.com',
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowedOrigins, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Credentials: true');
header('Vary: Origin');
}The Vary: Origin header is important too — it tells caches that the response differs by origin, so a cached response for one origin doesn't get served to another.
If you're on Laravel, configure this properly in config/cors.php. The allowed_origins key accepts an explicit list. Resist the urge to put ['*'] in there and walk away. Your future self — and your users — will thank you.
4. Timing Side-Channel on Token Comparison
This one is invisible in code review and almost never gets caught without specifically looking for it.
When you compare two strings with ===, PHP stops the moment it finds a mismatch. The earlier in the string the difference appears, the faster the comparison returns false. That time difference is measurable — sometimes in microseconds, sometimes in nanoseconds — but with enough requests, it's statistically consistent.
An attacker sends thousands of requests with tokens that vary by one character at a time. They measure response times. The token that takes slightly longer to reject shares more correct bytes with the real one. Byte by byte, they reconstruct your secret.
// Leaks timing information — DO NOT use for secrets
if ($submittedToken === $actualToken) { ... }
// Constant-time — always takes the same duration regardless of match position
if (hash_equals($actualToken, $submittedToken)) { ... }hash_equals() compares the entire string every time, unconditionally. Use it for API keys, CSRF tokens, webhook signatures, signed URLs — anything where the value being compared is a secret.
This isn't a theoretical cryptography problem. It's been demonstrated against production systems. One function call closes it completely.
5. Broken Object-Level Authorization in APIs
Your API is authenticated. Every route checks for a valid token. Reviews pass. Deploys go out.
And then someone notices they can access anyone's data by changing a number in the URL.
GET /api/reports/4821
DELETE /api/invoices/9034The developer checked: is this user logged in? They didn't check: does this user own object 4821?
// Authenticated — but not scoped to the user
public function show(Request $request, $reportId)
{
return Report::findOrFail($reportId);
}
// Authenticated AND authorized
public function show(Request $request, $reportId)
{
return Report::where('user_id', $request->user()->id)
->findOrFail($reportId);
}This is called Broken Object Level Authorization — OWASP has ranked it as the #1 API security risk for several years running. And it keeps showing up because authentication feels like authorization. They're not the same thing.
In Laravel, use policies and call $this->authorize() explicitly. Don't rely on remembering to scope the query every time. Policies centralize the ownership check so one missed where() clause can't open up the entire dataset.
6. Supply Chain Blind Spots in Composer Dependencies
This is the mistake that feels furthest from security until it's too late.
You added a package two years ago. It had 200k downloads, an active maintainer, a clean README. Today the maintainer has abandoned it, the package was re-registered under a different account, and the latest patch release contains a harvester for environment variables.
Your composer update pulled it in. Your CI passed because the tests passed. You deployed.
This happened to real projects. Not hypothetical ones.
# Run this. Add it to CI. Block the deploy if it fails.
composer auditcomposer audit checks your installed packages against the PHP Security Advisories database and surfaces known CVEs. It takes two seconds and costs nothing.
Beyond that:
Commit your composer.lock file. It pins every dependency to an exact version. Without it, two developers running composer install a week apart may get different package versions.
Set explicit version constraints for packages that have access to sensitive parts of your system — mailers, payment integrations, auth libraries. Don't use * or overly broad ^ constraints on packages you haven't reviewed.
Check who maintains a package before adding it. One utility function isn't worth a dependency on a package with a single anonymous maintainer and no activity for 18 months. Write it yourself.
Every package you add is a trust decision. Treat it like one.
What These Six Have in Common
None of these are about forgetting a function. They're not sloppy code. They're decisions that made complete sense at the time — and became vulnerabilities as the system, the threat landscape, or the team's assumptions changed.
The race condition logic reads like correct code. The JWT implementation follows the documentation. The API authentication is real. The packages were safe when you added them.
The gap isn't knowledge — it's the habit of asking "what is this trusting, and should it?" at every decision point, not just at the beginning of a project.
That question, applied consistently, is what separates code that works from code that holds up.
Checklist
- ✅ Use
lockForUpdate()inside a transaction for any check-then-act business logic - ✅ Pin the JWT algorithm explicitly — never let the token header decide
- ✅ Maintain an explicit CORS origin allowlist — never reflect
$_SERVER['HTTP_ORIGIN']blindly - ✅ Use
hash_equals()for every security-sensitive string comparison - ✅ Scope every query to the authenticated user — auth ≠ authorization
- ✅ Run
composer auditin CI and commit yourcomposer.lock
If this hit differently than the usual PHP security content — give it a clap (you can give up to 50). And follow if you want more audit-level backend writing. No beginner fluff, just the real stuff.