Skip to main content
Laravel, shipping fast.
Chapter 3 ยท Authentication & Security

Domain Validation: Binding Tokens to Origins

Julian Beaujardin

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.