savannabits/api-keys
A Laravel package for generating, managing, and authenticating API keys, with configurable models and middleware integration for protecting routes and tracking key-based access.
v0.0.1 is the first public tag, so treat this as an early-adopter package: read the source before wiring it into anything user-facing. The typical bootstrap for a Laravel API-key package like this is short:
Install
composer require savannabits/api-keys
Pin it exactly ("savannabits/api-keys": "0.0.1") — at 0.0.x every release can break.
Publish and migrate
php artisan vendor:publish --provider="Savannabits\ApiKeys\ApiKeysServiceProvider"
php artisan migrate
Look first at what lands in config/api-keys.php (table name, key length/prefix, hashing driver, header name, model class) and the generated migration. Those two files tell you more about the package's design than the README does.
Attach the trait to whatever authenticatable model should own keys — usually App\Models\User:
use Savannabits\ApiKeys\Concerns\HasApiKeys;
class User extends Authenticatable
{
use HasApiKeys;
}
Issue your first key (the plaintext is returned once, at creation):
$plain = $user->createApiKey('cli-test'); // name/label arg — check the signature
// store $plain somewhere safe for the test, it will never be retrievable again
Protect a route — either via the shipped middleware or a guard, depending on what the service provider registers:
Route::middleware('auth.api-key')->get('/user', fn () => request()->user());
First real use case: a machine-to-machine endpoint (webhook receiver, mobile client, CI integration) where you want per-client revocable credentials without standing up Passport/Sanctum OAuth.
One key per device, not per user. Give each consumer its own key row with a human-readable name ("mobile-ios", "zapier", "ci-runner"). Revocation then becomes a surgical operation instead of locking a user out everywhere.
Issue in a management controller, return plaintext once.
public function store(Request $request)
{
$request->validate(['name' => ['required', 'string', 'max:64']]);
$key = $request->user()->createApiKey($request->name);
// Only chance to show it: flash to session, display, then gone.
return back()->with('plain_text_key', $key->plainText ?? $key);
}
Never echo it in a JSON response that a browser or proxy might log; prefer a one-time flash view.
Middleware/guard at the route-group level. Group by consumer type so you can layer throttling and logging per group:
Route::prefix('api/v1')
->middleware(['auth.api-key', 'throttle:60,1'])
->group(base_path('routes/api-v1.php'));
If the package registers a guard driver instead, add it to config/auth.php under guards.api and use auth:api — mixing the middleware and guard styles in one app is a common source of "authenticated but $request->user() is null".
Abilities/scopes, if present. If the key model exposes an abilities array, check it in a thin custom middleware rather than sprinkling if statements through controllers. Keep the check in one place so adding a scope later is a one-line change.
Revocation and rotation as a first-class flow. Build a POST /api-keys/{key}/rotate that creates the new key, returns the plaintext, and only then deletes the old row — with a short grace window if your consumers can't hot-swap instantly:
DB::transaction(function () use ($user, $key) {
$new = $user->createApiKey($key->name.' (rotated)');
$key->update(['expires_at' => now()->addHours(24)]); // grace, don't delete yet
});
Housekeeping. If the package ships a prune/expire command, schedule it in routes/console.php or the kernel: expired keys that still authenticate are a silent liability.
Testing. Reset the guard between tests and hit routes with the header your config expects:
$plain = $user->createApiKey('test')->plainText ?? /* ... */;
$this->withHeader('Authorization', "Bearer {$plain}")
->getJson('/api/v1/user')
->assertOk();
Add one negative test per guard behaviour — missing header, malformed header, revoked key, expired key — because those are exactly the paths that regress between 0.0.x tags.
Sanctum/Passport coexistence. If the app already uses Sanctum for first-party SPAs, keep API-key auth on a separate guard and route group. Do not let both guards fall through on the same route: whichever resolves first wins and you get nondeterministic 401s.
$guarded = [] on the key model. Mass-assigning hashed_key, user_id, or expires_at from a request is a privilege-escalation bug. Check the shipped model's fillable list before you trust it.hash('sha256', ...) you get constant-time lookup by indexed hash; if it uses Hash::make() (bcrypt) it must iterate every candidate key — fine for 10 keys, painful for 10,000. Check the migration: an index on the lookup column is the tell.php artisan config:cache will happily serve stale api-keys.php values after you edit the header name or table. Clear the cache in deploys, and remember production env vars aren't read until then.create_api_keys_table. Guard it with Schema::hasTable() in local environments, or just don't publish twice.last_used_at writes are a hidden write-per-request. On a busy endpoint that's a row update on every call and will show up as lock contention. Consider throttling the touch (e.g. only if older than 60s) or pushing it to a queued job.sk_live_...) so secret scanners and git log grep can catch leaks. If the package doesn't do this at 0.0.1, wrap key generation in your own service rather than forking.expires_at stored in UTC but compared against now() in app timezone is the classic off-by-hours bug that makes a key work locally and 401 in production. Verify the cast and the comparison column.request()->user() resolution. If auth succeeds but user() is null, the guard isn't setting the resolved user on the request. Middleware that authenticates must call auth()->setUser($user) (or Auth::guard(...)->setUser()) — worth confirming with a two-line test before assuming it's your code.ApiKey model in config, a KeyGenerator/Hasher contract, and an event fired on key creation/revocation (ApiKeyCreated, ApiKeyRevoked). Those three make the package usable without forking; if they're missing at 0.0.1, wrap issuance in your own ApiKeyService so you have a seam to swap later.0.0.1 means no BC promise and possibly no tests in CI. Read the tests/ directory and the CHANGELOG before adopting; if it's empty, budget an afternoon to write your own integration tests around the four guard paths above before shipping.How can I help you explore Laravel packages today?