Laravel API key authentication: your throttle already ran
A fresh Laravel 13 install ships no routes/api.php. Then it gets worse: your throttle middleware runs before your API key middleware, so every customer shares an IP bucket.

Sorower
Co-founder

In this article
- The snippet everyone ships
- What breaks at customer number two
- Step 1: put the keys in a table, hashed
- Step 2: make the key find its own row
- The part no Laravel API key authentication tutorial covers: your throttle already ran
- The route order is not the run order
- Fix A: tell Laravel your middleware authenticates
- Fix B: key the limiter on the header directly
- Your 401 is HTML unless the path starts with api/
- When to stop building this
- Key takeaways
Fresh laravel/laravel install, Laravel 13.23.0, and the first thing I did was open routes/api.php to add an API key check. It isn't there. There's web.php, console.php, and that's it. You have to run php artisan install:api before Laravel API key authentication is even a question you can ask, and roughly none of the tutorials ranking for this today mention it.
That sets the tone for the whole exercise. The Laravel API key posts you'll find put one key in .env, compare it against a header with !==, return 401, and stop. Which is fine. Genuinely, for an internal service with one caller, that snippet is correct and you should ship it and go do something else.
This post is about the next paragraph. The one where you have a second customer.
The snippet everyone ships
Here it is, done properly for Laravel 11 and up, where app/Http/Kernel.php no longer exists and middleware aliases live in bootstrap/app.php:
// app/Http/Middleware/ApiKey.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class ApiKey
{
public function handle(Request $request, Closure $next)
{
$presented = (string) $request->header('X-API-Key');
if (! hash_equals(config('services.api_key'), $presented)) {
abort(401, 'Invalid API key.');
}
return $next($request);
}
}
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'apikey' => \App\Http\Middleware\ApiKey::class,
]);
})
Two things there that the ranking tutorials get wrong. The first is hash_equals instead of !==. PHP's hash_equals is the constant-time string comparator; !== short-circuits on the first differing byte, which leaks how much of the key an attacker got right. This is the same rule as Go's crypto/subtle.ConstantTimeCompare and Java's MessageDigest.isEqual, and it costs you nothing to get right on day one.
The second is the (string) cast. hash_equals is strict about types, and a missing header gives you null:
TypeError: hash_equals(): Argument #2 ($user_string) must be of type string, null given
A 500 on a missing header is a bad look for an auth layer. Cast it.
What breaks at customer number two
The single-key snippet has exactly one key, so every one of these follows from that:
- You can't revoke one caller. Rotating the key logs out everybody, at the same instant, with no warning.
- You can't tell who called. Your logs say "authenticated". They don't say by whom, which means no per-customer usage, no billing, and no way to answer "who hammered us at 3am".
- The key lives in an environment file, which means it lives in your deploy tooling, your CI secrets, and a Slack thread from 2024.
- Your rate limiter is bucketing by IP. This one is the interesting failure and it gets its own section, because the fix is not what you'd expect.
Rolling your own key layer is a two-day project that ships in six weeks. The two days are the middleware above. The six weeks are these four bullets.
Step 1: put the keys in a table, hashed
The storage question has a settled answer and Laravel ships a reference implementation of it, so you don't have to design anything. Store a fast hash of the key, never the key itself, and never a password KDF.
That last part surprises people, so: bcrypt is the wrong tool for API keys. It salts every call, which makes indexed lookup impossible and degrades verification to a full table scan, and it silently ignores everything past the first 72 bytes. We measured both failure modes and they are worse than they sound. A high-entropy random key doesn't need a slow hash, because there's no dictionary to attack.
SHA-256 over a 40-character random string is the right answer, and it's the answer Laravel Sanctum already uses.
Step 2: make the key find its own row
Here's the part worth stealing. A hashed key creates a lookup problem: you can't WHERE key = ? on something you didn't store. Sanctum solves it by putting the row's primary key in the token, in front of the secret. Generate one and take it apart:
$token = $user->createToken('demo');
echo $token->plainTextToken;
1|NUNSKz6vZzWs1XDKspqAAcyuXc87rDenC7cGqnSY207d64b5
Four separate jobs in one string:
1is thepersonal_access_tokensrow id. Sanctum splits on the pipe and does a primary-key lookup, so verification touches one indexed row instead of scanning the table.- The next 40 characters are the random secret.
- The final 8 characters (
207d64b5) are a CRC32b checksum of those 40. That's not decoration. It lets a leak scanner recognise a real token in a public repo without calling your API, which is why Sanctum also ships aSANCTUM_TOKEN_PREFIXconfig option pointing at GitHub's secret scanning docs. - The database column holds
hash('sha256', $secret), and the comparison ishash_equals.
You can verify the whole chain yourself in about four lines:
[$id, $secret] = explode('|', $token->plainTextToken, 2);
echo hash('sha256', $secret) === $token->accessToken->token ? "match\n" : "no\n";
echo substr($secret, -8) === hash('crc32b', substr($secret, 0, 40)) ? "checksum ok\n" : "no\n";
match
checksum ok
So do I just use Sanctum then? If your callers are your own SPA or mobile app, yes, and stop reading. Sanctum's model is a token that belongs to a User row. If your callers are other companies' servers, that model fights you: a customer is not a user, one customer wants several keys with different scopes, and nobody wants a users row for a machine. Take the token shape from Sanctum. Own the table.
The part no Laravel API key authentication tutorial covers: your throttle already ran
Laravel's throttle middleware picks its bucket like this, straight out of ThrottleRequests:
if ($user = $request->user()) {
return $this->formatIdentifier($user->getAuthIdentifier());
} elseif ($route = $request->route()) {
return $this->formatIdentifier($route->getDomain().'|'.$request->ip());
}
No user, no per-caller bucket. It falls back to the IP. And since your API key middleware doesn't set a user, that's the branch you're on.
A rate limiter keyed on IP isn't limiting your customers, it's limiting their hosting provider. Two customers behind one NAT or one cloud egress address share a bucket. Here's a route with throttle:3,1, three requests from key alpha, then the very first request ever made with key bravo:
for i in 1 2 3; do
curl -s -o /dev/null -w "alpha %{http_code}\n" \
-H "X-API-Key: demo_key_alpha" http://127.0.0.1:8391/api/naive
done
curl -s -o /dev/null -w "bravo %{http_code}\n" \
-H "X-API-Key: demo_key_bravo" http://127.0.0.1:8391/api/naive
alpha 200
alpha 200
alpha 200
bravo 429
Bravo's first request is rejected. It has done nothing. Oof.
The obvious fix is to have your middleware resolve the caller and publish it with setUserResolver(), so the throttle finds a user. Except it doesn't work, and this is the bit that took me a while.
The route order is not the run order
Declare the route so your middleware is unambiguously first:
Route::get('/resolving', fn (Request $r) => response()->json(['ok' => true]))
->middleware(['apikey.resolving', 'throttle:3,1']);
Then log what actually happens:
ORDER: throttle ran; user=NULL
ORDER: apikey.resolving ran
Backwards. Laravel keeps a $middlewarePriority array in the HTTP kernel and re-sorts the stack against it, and ThrottleRequests is in that list while your class is not. Your middleware runs after the throttle no matter what order the route declares. Anything you resolve in a plain custom middleware is invisible to every framework middleware that sorts above it.
There are two fixes and they're both one-liners.
Fix A: tell Laravel your middleware authenticates
Look one line up in that priority array and you'll find Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests, sitting directly above ThrottleRequests. It's an empty marker interface. Implement it:
use Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests;
class ApiKey implements AuthenticatesRequests
{
public function handle(Request $request, Closure $next)
{
$presented = (string) $request->header('X-API-Key');
$consumer = $this->consumers->findByKey($presented);
if ($consumer === null) {
abort(401, 'Invalid API key.');
}
$request->setUserResolver(fn () => $consumer);
return $next($request);
}
}
Same probe, same route, same IP:
ORDER: apikey.priority ran
ORDER: throttle ran; user=App\Support\ApiConsumer:consumer_1
alpha 200
alpha 200
alpha 200
alpha 429 <- alpha's own bucket, correctly exhausted
bravo 200 <- different key, same IP, untouched
The object you hand to setUserResolver only needs to satisfy Illuminate\Contracts\Auth\Authenticatable, and the throttle only ever calls getAuthIdentifier() on it. It does not need to be an Eloquent model and it certainly doesn't need a users row. We hit the same seam in Django REST Framework, where the identity was resolved and then thrown away before the throttle looked for it. Different framework, identical bug.
Fix B: key the limiter on the header directly
If you'd rather not touch the middleware's interface, define a named limiter that never asks about the user:
// app/Providers/AppServiceProvider.php
public function boot(): void
{
RateLimiter::for('per-key', function (Request $request) {
return Limit::perMinute(60)->by((string) $request->header('X-API-Key'));
});
}
Route::get('/widgets', ...)->middleware(['apikey', 'throttle:per-key']);
Verified against the same probe: alpha exhausts at request 4, bravo's first request returns 200. This works even with the naive middleware, because the closure reads the header instead of $request->user().
| Approach | Buckets on | Limitation |
|---|---|---|
Implement AuthenticatesRequests |
The resolved consumer's identifier | Relies on a framework priority list that is an implementation detail. Nothing warns you if it changes. |
| Named limiter keyed on the header | The raw header value | Buckets an unvalidated string, so a caller sending garbage keys gets a fresh bucket per string. Combine with a strict 401 path and a separate IP limit. |
Leave it (route-level throttle:60,1) |
Domain plus client IP | Customers behind shared egress throttle each other. Callers who rotate X-Forwarded-For can dodge it entirely. |
Does throttle:api have the same problem? Yes. It's the same middleware; only the limit values differ. Any limiter that falls through to $request->user() being null lands on the IP branch.
If you want the wider tour of Laravel's limiter beyond the key-bucketing question, Laravel Daily covers the default and custom throttle setup well:
One more thing worth knowing while you're here: Laravel's 429 ships the legacy X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers alongside Retry-After. Those aren't the standard-track fields, and which headers you send changes what clients do when they back off.
Your 401 is HTML unless the path starts with api/
Laravel 13's default bootstrap/app.php contains this:
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
})
Read the handler and you'll see that callback replaces the default rather than adding to it:
return $this->shouldRenderJsonWhenCallback
? call_user_func($this->shouldRenderJsonWhenCallback, $request, $e)
: $request->expectsJson();
So the decision is now purely a path prefix, and expectsJson() never gets consulted. Mount your API at /v1/ instead of /api/v1/ and watch a client that explicitly asked for JSON get an HTML error page:
curl -s -D - -H "X-API-Key: wrong" -H "Accept: application/json" \
http://127.0.0.1:8391/v1/widgets | head -2
HTTP/1.1 401 Unauthorized
Content-Type: text/html; charset=utf-8
Under api/* the same request behaves:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"message": "Invalid API key."
}
Either widen the callback to $request->is('api/*') || $request->expectsJson(), or match your real prefix. And note that neither response carries a WWW-Authenticate header, which a 401 is supposed to have. abort(401) won't add one for you.
When to stop building this
Everything above is genuinely worth owning if keys are close to your product. Storage, hashing, the lookup handle, the throttle seam: that's maybe a week, and now you understand your own auth layer.
What takes the other five weeks is the operational half. Credit balances that refill on a schedule. Per-consumer limits you can change without a deploy. A revocation that propagates to every region before the leaked key is used again. Usage records accurate enough to invoice from. None of that is hard, exactly. It's just a lot, and it's not your product.
That's the layer ReqKey is. Your Laravel app asks it whether a key is good and how many credits to charge:
use Illuminate\Support\Facades\Http;
$response = Http::withToken(config('services.reqkey.root_key'))
->timeout(2)
->post('https://api.reqkey.com/key/validate', [
'key' => $presented,
'credits' => 1,
'resource' => $request->path(),
]);
if ($response->status() === 402) {
abort(402, 'Credit limit exceeded.');
}
if ($response->failed() || ! $response->json('valid')) {
abort(401, 'Invalid API key.');
}
{
"valid": true,
"requestId": "abc123xyz",
"creditsRemaining": 9995,
"creditsLimit": 10000,
"allowedApis": ["api_payment", "api_analytics"]
}
A revoked key comes back 200 with valid: false, an exhausted credit pool is 402, and a disabled key or consumer is 403. The full table is in the error reference. Or skip the hand-rolled call: composer require reqkey/reqkey installs the PHP SDK, whose service provider registers a reqkey middleware alias, and which defaults to failing closed when it can't reach the API. Whether that default is right for you is a decision worth making deliberately rather than discovering during an incident.
Two honest caveats, because you'll hit them. The ReqKey middleware doesn't call setUserResolver() either, so pairing it with Laravel's throttle:60,1 puts you right back on the IP branch. Use ReqKey's own per-consumer rate limits, or Fix B above. And a validation hop is a network call in your request path, so budget for the latency and set a timeout you're happy with. Local checks are always faster than remote ones; you're trading that for not maintaining any of this.
Key takeaways
- Run
php artisan install:apifirst. Laravel 13 ships noroutes/api.php, so half the tutorials you'll read start from a file you don't have. - Use
hash_equals, and cast the header to a string.!==leaks a prefix through timing, andhash_equalsthrows a TypeError on the null you get when the header is missing. - Steal Sanctum's token shape, not its data model. Row id, then random secret, then a CRC32b checksum, with SHA-256 at rest. You get indexed lookups and leak-scanner support for free; you don't get a
usersrow you didn't want. - Your throttle runs before your middleware. Route order is not run order. Implement
AuthenticatesRequestsor key a named limiter on the header, or you're rate limiting an IP address you don't control. - Check what your 401 actually returns. Outside
api/*, Laravel 13 sends HTML even to a client that asked for JSON, and it never sendsWWW-Authenticate.
Every probe in this post took about ten minutes against a fresh install. Run them against yours before a customer does. And if the part you don't want to own is the credits, the per-consumer limits and the revocation propagation rather than the middleware, ReqKey's free tier covers 100,000 requests a month, which is plenty to point a staging Laravel app at and see whether the trade is worth it.



