0

Token-based auth — API token guard

Advanced5 min read·fw-09-003
security

Concept

Token-based auth is the standard for APIs. Instead of sessions, each request includes a token in the Authorization header. The server validates the token and identifies the user.

Bearer token format: Authorization: Bearer <token>. The Bearer scheme is the standard for access tokens.

Token storage:

  • Database: Tokens stored in a personal_access_tokens table. Hash the token (store hash('sha256', $token)). Never store plaintext tokens — if the DB is compromised, all tokens are invalidated.
  • Stateless (JWT): Tokens are self-contained and signed. No database lookup needed. Can't be revoked without a blocklist.
  • Memory/Redis: For short-lived session-like tokens.

Token resolution per request:

  1. Extract token from Authorization: Bearer <token> header.
  2. Hash it: hash('sha256', $token).
  3. Look up the hash in the personal_access_tokens table.
  4. Check if the token is expired (expires_at).
  5. Load the tokenable model (user).

Scopes/Abilities: Tokens can have abilities (e.g., ['read:posts', 'write:posts']). The auth system can check if a token has a specific ability before allowing an action.

Token creation: $user->createToken('name', ['read:posts']) returns a NewAccessToken object. The plainTextToken property is shown ONCE to the user — store it, you can't retrieve it again.

Code Example

php
<?php
namespace Framework\Auth;

class TokenGuard implements GuardInterface
{
    private ?Authenticatable $resolvedUser = null;

    public function __construct(
        private readonly UserProviderInterface $provider,
        private readonly \Framework\Http\Request $request,
        private readonly \Framework\Database\Connection $db,
    ) {}

    public function check(): bool  { return $this->user() !== null; }
    public function guest(): bool  { return !$this->check(); }
    public function id(): int|string|null { return $this->user()?->getAuthIdentifier(); }

    public function user(): ?Authenticatable
    {
        if ($this->resolvedUser !== null) return $this->resolvedUser;

        $token = $this->extractToken();
        if ($token === null) return null;

        $hashed = hash('sha256', $token);
        $row = $this->db->selectOne(
            'SELECT * FROM personal_access_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > ?)',
            [$hashed, date('Y-m-d H:i:s')]
        );

        if ($row === null) return null;

        // Update last used timestamp
        $this->db->update(
            'UPDATE personal_access_tokens SET last_used_at = ? WHERE id = ?',
            [date('Y-m-d H:i:s'), $row['id']]
        );

        return $this->resolvedUser = $this->provider->findById($row['tokenable_id']);
    }

    public function login(Authenticatable $user, bool $remember = false): void
    {
        $this->resolvedUser = $user; // programmatic login for this request
    }

    public function logout(): void
    {
        $token  = $this->extractToken();
        if ($token) {
            $this->db->delete(
                'DELETE FROM personal_access_tokens WHERE token = ?',
                [hash('sha256', $token)]
            );
        }
        $this->resolvedUser = null;
    }

    public function attempt(array $credentials): bool { return false; } // Not applicable for API guard
    public function validate(array $credentials): bool { return false; }

    public function createToken(Authenticatable $user, string $name, array $abilities = ['*']): string
    {
        $plaintext = bin2hex(random_bytes(40)); // 80 char token
        $this->db->insert(
            'INSERT INTO personal_access_tokens (tokenable_type, tokenable_id, name, token, abilities, created_at) VALUES (?, ?, ?, ?, ?, ?)',
            [get_class($user), $user->getAuthIdentifier(), $name, hash('sha256', $plaintext), json_encode($abilities), date('Y-m-d H:i:s')]
        );
        return $plaintext; // show once — never stored in plaintext
    }

    private function extractToken(): ?string
    {
        $header = $this->request->header('Authorization');
        if ($header && str_starts_with($header, 'Bearer ')) {
            return substr($header, 7);
        }
        return null;
    }
}