ALL POSTS
laravelapi keysphprate limiting

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

Sorower

Co-founder

Aug 1, 202612 min read
In this article

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

Infographic showing four failures of a single environment-variable API key: one shared key, no storage, no identity, and a shared rate limit bucket

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:

Infographic breaking an API token into four parts: lookup handle, random secret, checksum, and a fast hash at rest
  • 1 is the personal_access_tokens row 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 a SANCTUM_TOKEN_PREFIX config option pointing at GitHub's secret scanning docs.
  • The database column holds hash('sha256', $secret), and the comparison is hash_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

Diagram of Laravel middleware execution: the route lists your middleware first, Laravel sorts by priority, throttle runs while the user is still null, and the key middleware runs too late

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().

Two ways to stop bucketing your customers by IP
ApproachBuckets onLimitation
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:

Laravel Daily video: Laravel API Rate Limiting, Default and Custom Throttle

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:api first. Laravel 13 ships no routes/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, and hash_equals throws 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 users row you didn't want.
  • Your throttle runs before your middleware. Route order is not run order. Implement AuthenticatesRequests or 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 sends WWW-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.

Share this post

Put your API keys on autopilot.

Keys, credits, plans, and real-time traffic analytics — free for your first 100k requests a month.