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_tokenstable. Hash the token (storehash('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:
- Extract token from
Authorization: Bearer <token>header. - Hash it:
hash('sha256', $token). - Look up the hash in the
personal_access_tokenstable. - Check if the token is expired (
expires_at). - 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;
}
}