Before shipping:
- [X] Bearer tokens are stateless and validated from database
- [X] Tokens are cached to minimize database queries
- [X] Token expiration is enforced (stale tokens rejected immediately)
- [X] Token revocation is possible (delete or expire token)
- [X] Domain validation restricts token usage to approved origins
- [X] Rate limiting prevents abuse at the token level, not IP level
- [X] Rate limit headers inform clients of quota
- [X] Logging is asynchronous (doesn't block responses)
- [X] Tokens are never logged in plain text (hashed only)
- [X] Configuration is dynamic per token (no hardcoded secrets)
- [X] All protected routes use the same middleware stack
- [X] FormRequests validate all input (boundary validation)
- [X] All endpoints return consistent error structure
- [X] SQL injection prevented (Eloquent ORM, parameterized queries)
- [X] HTTPS enforced (configure in production)
Security isn't a feature. It's not a sprint. It's a discipline. Follow these patterns, test them, and your API is protected by architecture, not good intentions.
Threat Scenarios: Real-World Attacks
Your architecture prevents concrete security threats:
Token Theft: Attacker steals Bearer token. With domain validation, token only works from allowed domain. Attacker from attacker.com gets 401.
DOS Attacks: Malicious client sends 10,000 requests/min. Per-token rate limiting stops them after 60/min. Server stays healthy.
Token Enumeration: Attacker tries random tokens. Token hashing prevents timing attacks (constant response time).
Expired Token Reuse: Revoked token (expired 1 hour ago). Expiration checking rejects it immediately.
Information Disclosure: Bad: {\"error\": \"Token abc123 not found\"}. Good: generic 401 Unauthorized. Don't expose implementation.
Credential Logging: Never log plain Authorization: Bearer secret. Log hash: hash('sha256', $token). Correlate without exposing.
Error Response Formats
401 Unauthorized (missing or invalid token)
{\"errors\": [{\"message\": \"Unauthenticated\", \"code\": \"401\"}]}
422 Unprocessable Entity (validation failed)
{\"errors\": [{\"field\": \"name\", \"message\": \"The name is required.\"}, {\"field\": \"domain\", \"message\": \"Invalid format.\"}]}
429 Too Many Requests (rate limit exceeded)
{\"message\": \"Too Many Requests\"}
Headers: X-RateLimit-Limit: 60, X-RateLimit-Remaining: 0, Retry-After: 42
503 Service Unavailable (external dependency down)
{\"message\": \"The service is temporarily unavailable. Please try again later.\"}
Header: Retry-After: 30
Testing Authentication & Security
Testing authentication is straightforward because Bearer tokens are just database entries. Here are comprehensive tests covering happy paths and edge cases:
// Happy path: valid token
test('valid bearer token is accepted', function () {
$bearer = Bearer::factory()->create();
$response = $this->withHeader('Authorization', "Bearer {$bearer->token}")
->get('/api/licenses');
expect($response->status())->toBe(200);
});
// Missing token
test('request without bearer token is rejected', function () {
$response = $this->get('/api/licenses');
expect($response->status())->toBe(401);
});
// Expired token
test('expired bearer token is rejected', function () {
$bearer = Bearer::factory()
->expired()
->create();
$response = $this->withHeader('Authorization', "Bearer {$bearer->token}")
->get('/api/licenses');
expect($response->status())->toBe(401);
});
// Invalid/non-existent token
test('non-existent bearer token is rejected', function () {
$response = $this->withHeader('Authorization', 'Bearer invalid_token_xyz')
->get('/api/licenses');
expect($response->status())->toBe(401);
});
// Domain restriction
test('bearer token is rejected if domain does not match', function () {
$bearer = Bearer::factory()
->withDomains(['https://allowed-domain.com'])
->create();
$response = $this->withHeader('Authorization', "Bearer {$bearer->token}")
->from('https://wrong-domain.com')
->get('/api/licenses');
expect($response->status())->toBe(401);
});
// Domain restriction passes when domain matches
test('bearer token is accepted when domain matches', function () {
$bearer = Bearer::factory()
->withDomains(['https://allowed-domain.com'])
->create();
$response = $this->withHeader('Authorization', "Bearer {$bearer->token}")
->from('https://allowed-domain.com')
->get('/api/licenses');
expect($response->status())->toBe(200);
});
// Rate limiting
test('rate limit is enforced after 60 requests per minute', function () {
$bearer = Bearer::factory()->create();
$headers = ['Authorization' => "Bearer {$bearer->token}"];
// Make requests up to limit
for ($i = 0; $i < 60; $i++) {
$this->withHeaders($headers)->get('/api/licenses');
}
// Next request should be rate limited
$response = $this->withHeaders($headers)->get('/api/licenses');
expect($response->status())->toBe(429);
});
// Malformed authorization header
test('malformed authorization header is rejected', function () {
$response = $this->withHeader('Authorization', 'NotBearer sometoken')
->get('/api/licenses');
expect($response->status())->toBe(401);
});
// Rate limit headers included
test('rate limit headers are included in response', function () {
$bearer = Bearer::factory()->create();
$headers = ['Authorization' => \"Bearer {$bearer->token}\"];
$response = $this->withHeaders($headers)->get('/api/licenses');
expect($response->headers->has('X-RateLimit-Limit'))
->and($response->headers->get('X-RateLimit-Limit'))->toBe('60')
->and($response->headers->has('X-RateLimit-Remaining'))
->and($response->headers->has('X-RateLimit-Reset'));
});
// Token caching benefits
test('bearer token is cached after first lookup', function () {
$bearer = Bearer::factory()->create();
$token = $bearer->token;
// First request: database query for token validation
$response = $this->withHeader('Authorization', \"Bearer {$token}\")
->get('/api/licenses');
expect($response->status())->toBe(200);
// Second request in same minute: cache hit (no additional DB query)
$response = $this->withHeader('Authorization', \"Bearer {$token}\")
->get('/api/licenses');
expect($response->status())->toBe(200);
// In production, verify via query log that only 1-2 lookups total
});
// Async logging doesn't block response
test('request completes even if logging fails or is slow', function () {
$bearer = Bearer::factory()->create();
$headers = ['Authorization' => \"Bearer {$bearer->token}\"];
// Request returns immediately (logging queued, not awaited)
$response = $this->withHeaders($headers)->get('/api/licenses');
expect($response->status())->toBe(200);
// Logging happens in background queue, doesn't add latency
});
By creating fixtures in your factory, testing different scenarios is trivial. No mocking HTTP clients, no stubbing external APIs. Just Bearer model states and assertions. This coverage ensures your authentication layer handles both happy paths and edge cases correctly.