Domain Validation: Binding Tokens to Origins
Tokens can restrict which domains are allowed to use them. This is crucial for partner scenarios.
Imagine you issue a token to Partner A. They promise to only use it from partner-a.com. But what if their credentials are stolen? An attacker gets the token but runs from attacker.com. Without domain validation, the token still works. With domain validation, it fails.
Configure token domain restrictions in the factory:
// database/factories/BearerFactory.php
class BearerFactory extends Factory
{
public function definition(): array
{
return [
'token' => Token::generateToken(),
'expires_at' => now()->addYear(),
'domains' => null, // No domain restrictions by default
'settings' => [...],
];
}
public function withDomains(array $domains): self
{
return $this->state(fn (array $attributes) => [
'domains' => $domains,
]);
}
}
// Usage in code or tests:
$token = Bearer::factory()
->withDomains(['https://partner-a.com', 'https://partner-a-staging.com'])
->create();
Middleware checks domains:
private function isTokenValidForDomain(Request $request, Bearer $token): bool
{
$domains = $token->domains;
// No domain config? Always allow
if (empty($domains) || ! config('bearer.verify_domains', false)) {
return true;
}
// Handle JSON storage edge case
if (is_string($domains)) {
$decoded = json_decode($domains, true);
if (! is_array($decoded)) {
return false; // Malformed domains data
}
$domains = $decoded;
}
// Check if request origin is whitelisted
return in_array($request->getSchemeAndHttpHost(), $domains, true);
}
getSchemeAndHttpHost() returns the full origin: https://partner-a.com. Checking it against the whitelist ensures the token only works from expected domains.
Request Logging: Async Audit Trail
Every API request should be logged for compliance, debugging, and monitoring. But logging is I/O intensive. Writing to a log file on every request adds latency. Instead, queue a log job:
// app/Http/Middleware/LogApiRequestsMiddleware.php
class LogApiRequestsMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Queue logging job asynchronously
defer(callback: fn () => SendToLogsJob::dispatch(
method: $request->method(),
path: $request->path(),
status: $response->status(),
token: $request->bearerToken(),
user_agent: $request->header('User-Agent'),
timestamp: now(),
));
return $response;
}
}
// app/Jobs/SendToLogsJob.php
class SendToLogsJob implements ShouldQueue
{
public function __construct(
public string $method,
public string $path,
public int $status,
public ?string $token,
public ?string $userAgent,
public Carbon $timestamp,
) {}
public function handle(): void
{
Log::info('API Request', [
'method' => $this->method,
'path' => $this->path,
'status' => $this->status,
'token_hash' => $this->token ? hash('sha256', $this->token) : null, // Never log plain token!
'user_agent' => $this->userAgent,
'timestamp' => $this->timestamp->toIso8601String(),
]);
}
}
Notice: The token is hashed, not stored plain. This is security 101: never log credentials. But we log a hash so we can correlate requests to a specific token without exposing the actual secret.
Using defer() (introduced in Laravel 12) queues the job immediately but doesn't wait for it. The response is sent instantly. Logging happens asynchronously in the background. This is the same pattern as Chapter 2's async deletion pattern. It keeps your API fast by separating I/O concerns.
Compare response times:
- Without async logging: Every request writes to logs before responding. 50-200ms added per request.
- With async logging: Logging queued. Response sent immediately. Job processes in background.
For 1000 requests/min, that's a huge difference.