ejarnutowski/laravel-api-key
ejarnutowski/laravel-api-key adds lightweight API key authentication to Laravel applications. Create keys, attach them to users or any model, and guard routes with middleware—a simple alternative to Passport or Sanctum for token-based API access.
The package is deliberately small: one model, one migration, one middleware, one config file. You can read the whole thing in ~5 minutes, which is the right first move before wiring it in.
Install and scaffold
composer require ejarnutowski/laravel-api-key
php artisan vendor:publish --provider="Ejarnutowski\LaravelApiKey\Providers\ApiKeyServiceProvider"
php artisan migrate
Publishing gives you config/api-key.php (most importantly the header name, defaulting to something like X-Api-Key) and the api_keys migration. Read the published config first — it is the only knob most teams ever touch.
Confirm the middleware alias
Older Laravel versions required manual registration in app/Http/Kernel.php; on Laravel 11+ check bootstrap/app.php. If the package doesn't self-register, add:
protected $routeMiddleware = [
// ...
'api-key' => \Ejarnutowski\LaravelApiKey\Http\Middleware\ApiKeyAuthorization::class,
];
Mint your first key and protect a route
use Ejarnutowski\LaravelApiKey\Models\ApiKey;
$key = ApiKey::create(['name' => 'acme-integration']);
// $key->key -> the UUID you hand to the client
// routes/api.php
Route::middleware('api-key')->get('/orders', [OrderController::class, 'index']);
curl -H "X-Api-Key: 5f2c...-..." https://app.test/api/orders
First realistic use case: a single external integration or an internal service calling your API where you want a static, revocable credential without standing up OAuth. If you need user login, token refresh, or scopes, stop here and use Sanctum instead — this package solves exactly one problem.
Route-group scoping. Don't sprinkle the middleware on individual routes; wrap the whole client-facing surface:
Route::middleware(['api-key', 'throttle:60,1'])->prefix('v1')->group(function () {
Route::apiResource('orders', OrderController::class);
});
Named keys as an audit primitive. The name column is the only metadata you get out of the box. Use it as {consumer}-{environment} (acme-prod, acme-staging, internal-billing) so revocation and log correlation stay sane. Write the name — never the key — into your logs.
Key rotation without downtime. There is no grace-period logic; implement it as data:
ApiKey::create(['name' => 'acme-prod-v2'])Because lookups are a plain where('key', ...), you can also add an expires_at column and a global scope — no fork required.
Attaching context to the request. The middleware resolves the key; the hard part is what happens next. If keys are owned by users, add a user_id column and a HasApiKeys trait relation, then either override the middleware or add a thin one after it:
class ResolveApiKeyUser
{
public function handle($request, Closure $next)
{
$key = $request->attributes->get('api_key'); // confirm the attribute name in the package middleware
if ($key?->user) {
Auth::setUser($key->user);
}
return $next($request);
}
}
Verify the exact attribute/container binding the middleware exposes by reading its handle() method — it's short, and guessing here is the #1 source of "why is auth()->user() null" bugs.
Adding abilities/scopes. Add abilities (JSON) and check it in a policy or a second middleware. This is the natural extension point: $apiKey->can('orders:write'). The package gives you the identity; authorization stays yours.
Overriding the middleware. Rather than editing vendor code, subclass it, change the alias to point at yours, or re-register the route group. Everything in the package is final-free enough to extend, but subclassing is safer against a future composer update.
Testing.
$key = ApiKey::create(['name' => 'test']);
$this->withHeader('X-Api-Key', $key->key)
->getJson('/api/orders')
->assertOk();
Use RefreshDatabase and a small factory for the model. Test the 401 paths explicitly: missing header, malformed key, and a deleted/revoked key.
Rate limiting keyed by credential.
RateLimiter::for('api-key', fn ($request) =>
Limit::perMinute(120)->by($request->header('X-Api-Key') ?: $request->ip())
);
This keeps one noisy client from starving everyone behind the same NAT'd IP.
Plaintext keys in the database. Keys are stored as-is (UUIDs). That's fine for low-risk internal APIs, but treat api_keys as a secrets table — restrict DB access, exclude it from dumps, and never log the key column. If your threat model demands hashed keys, hash on create and use a constant-time comparison in a custom middleware; you're halfway to rewriting the package anyway.
Header parsing is literal. Configuring header_name => 'Authorization' does not give you Bearer <token> parsing — the raw header value is compared. Either keep X-Api-Key or strip the scheme in a middleware that runs first.
Config caching. Changing the header name in production without php artisan config:clear (or redeploying with config:cache) silently produces 401s against a stale value. Same class of bug: the published config key name differs across major versions — trust the file you published, not the README.
401 vs 404 confusion. If the route returns 404 rather than 401, the middleware isn't attached — confirm with php artisan route:list --path=api. If it returns 401 with a key you just created, check for a trailing newline in the stored key (common when keys come through a dashboard copy-paste) and whether the key row was soft-deleted but still resolvable by your query.
Soft deletes and revocation. If you enable SoftDeletes on the model, the default scope handles it — but if you write custom lookups (ApiKey::withoutGlobalScopes()->where(...)), you'll resurrect revoked keys. Prefer hard deletes for revocation; reserve soft deletes for audit trails you query explicitly.
Migration collisions. Publishing the migration onto an existing api_keys table fails. Rename the published file with a later timestamp, or guard the Schema::create with Schema::hasTable(). Also mind foreign-key ordering if you add user_id.
Middleware ordering. Put api-key after throttle (so unauthenticated floods are cheap) and decide deliberately whether it runs before or after auth:sanctum. Running both is legitimate for mixed clients, but make the "either/or" explicit in a custom guard rather than letting two middlewares fight over Auth.
No expiry, no scopes, no rotation, no hashing. These are the four things every team adds within a month. Budget for that up front; if all four are required, Sanctum or a 60-line in-house implementation is a better fit than extending this one.
Maintenance risk. 157 stars and a low opportunity score means infrequent releases and a narrow Laravel version constraint in composer.json. Before adopting, run composer why-not against your Laravel version, and pin the release. The package is small enough (~a model, a migration, a middleware, a config) that vendoring it into app/ is a defensible fallback if upstream goes quiet — you keep the behavior and lose the upgrade risk.
Debugging shortcut. The middleware is the entire authorization surface: add Log::debug('api-key check', ['present' => $request->hasHeader(...), 'name' => $key?->name]) at the top of a subclassed copy while diagnosing, then remove it. Never log $key->key.
How can I help you explore Laravel packages today?