API Key Authentication for Web Applications
API keys are the simplest authentication mechanism for machine-to-machine interactions (server-to-server). No OAuth flow, no refresh tokens, no sessions. The client passes the key in a request header or query parameter, and the server validates it. Suitable for public APIs, partner integrations, and CLI tools.
Key Generation and Storage
The key must be sufficiently random—minimum 32 bytes:
// Generate key
$key = 'sk_' . bin2hex(random_bytes(32)); // sk_ + 64 hex = 67 characters
// Example: sk_a3f9b12e8c4d7e1f0a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5
// Never store the key in plaintext—only hash it
$hash = hash('sha256', $key);
DB::table('api_keys')->insert([
'user_id' => $userId,
'name' => $request->name,
'key_prefix' => substr($key, 0, 8), // for display to user
'key_hash' => $hash,
'scopes' => json_encode(['read:articles', 'write:articles']),
'last_used_at' => null,
'expires_at' => now()->addYear(),
]);
// Show the key to the user ONLY ONCE—upon creation
return response()->json(['key' => $key], 201);
Store only the hash—if the database is breached, the keys are useless.
Key Verification
// ApiKeyAuth Middleware
public function handle(Request $request, Closure $next): Response
{
$key = $request->bearerToken() // Authorization: Bearer sk_...
?? $request->header('X-Api-Key') // X-Api-Key: sk_...
?? $request->query('api_key'); // ?api_key=sk_... (avoid in URL)
if (!$key) {
return response()->json(['error' => 'API key required'], 401);
}
$hash = hash('sha256', $key);
$apiKey = ApiKey::where('key_hash', $hash)
->where(fn($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->first();
if (!$apiKey) {
return response()->json(['error' => 'Invalid or expired API key'], 401);
}
// Update last_used_at asynchronously to avoid slowing down requests
dispatch(fn() => $apiKey->update(['last_used_at' => now()]))->afterResponse();
$request->setUserResolver(fn() => $apiKey->user);
$request->attributes->set('api_key', $apiKey);
return $next($request);
}
Scopes (Permissions)
The key should have the minimum necessary permissions:
// Check scope in controller or Middleware
public function store(Request $request): JsonResponse
{
$apiKey = $request->attributes->get('api_key');
if (!in_array('write:articles', $apiKey->scopes ?? [])) {
return response()->json(['error' => 'Insufficient scope'], 403);
}
// ...
}
The scope list is defined when the user creates the key (or a fixed set is assigned).
Security
Key transmission:
- Only via HTTPS
- Preferably in the
Authorization: BearerorX-Api-Keyheader - Not in the URL (appears in logs, browser history, referer)
Key rotation:
// Invalidate the old key when creating a new one
ApiKey::where('user_id', $userId)->where('name', $name)->delete();
Usage audit:
ApiKeyUsageLog::create([
'api_key_id' => $apiKey->id,
'ip' => $request->ip(),
'endpoint' => $request->path(),
'method' => $request->method(),
'status' => null, // filled in Terminate middleware
]);
Rate limiting by key:
RateLimiter::for('api-key', function (Request $request) {
$apiKey = $request->attributes->get('api_key');
return Limit::perMinute($apiKey->rate_limit ?? 60)->by($apiKey->id);
});
User Interface in Dashboard
List of keys showing key_prefix (first 8 characters):
sk_a3f9b1... "Production integration" Last used: 2 hours ago [Delete]
sk_c2d4e5... "Staging webhook" Never used [Delete]
Adding a "Show key" button is not possible—the key is not stored. Only recreate it.
Timeline
API keys table, verification middleware, UI for key creation/deletion, rate limiting by key: 1–2 days.







