Skip to content

Authentication and rate limiting

Namespaces Keel\Auth and Keel\RateLimit. Both work through route attributes that enforce themselves: #[Auth] adds its guard middleware to the route, #[RateLimit] likewise, so a route cannot be protected on paper and open at runtime. The OpenAPI generator reads the same attributes.

Principal

Whoever a request was authenticated as implements Principal (id(), roles(), hasRole()). Identity is the plain implementation: an id, roles and free-form attributes. Handlers receive the principal by type, like the session:

#[Get('/me')]
#[Auth]
public function me(Principal $user): array { return ['id' => $user->id()]; }

#[Get('/feed')]
public function feed(?Principal $user = null): array { /* personalised when signed in */ }

Authenticators

An Authenticator recognises one credential scheme: it returns null when the request carries no credentials of its kind, a Principal when they are valid, and throws UnauthorizedException when they are present but wrong (a bad token is never anonymous). Built in:

Authenticator Reads Needs
BearerTokenAuthenticator Authorization: Bearer <token> a PrincipalResolver
ApiKeyAuthenticator X-Api-Key: <key> (header configurable) a PrincipalResolver
HmacAuthenticator Authorization: HMAC <keyId>:<timestamp>:<signature> HmacSecrets
Jwt\JwtAuthenticator Authorization: Bearer <JWT> JwtConfig: a secret, public keys or a JWKS URL

PrincipalResolver::resolve(string $credential): ?Principal and HmacSecrets::secretFor(string $keyId): ?string are yours to implement (database, cache, a static map); register them under the interface, with #[Alias] or in a provider:

#[Alias(PrincipalResolver::class)]
final class ApiTokens implements PrincipalResolver
{
    public function __construct(private readonly TokenRepository $tokens) {}

    public function resolve(string $credential): ?Principal
    {
        $row = $this->tokens->find(hash('sha256', $credential));

        return $row === null || $row->expired() ? null : new Identity($row->userId, $row->roles);
    }
}

config/auth.php lists the authenticators to try, in order:

return new AuthConfig([BearerTokenAuthenticator::class, HmacAuthenticator::class]);

Each is a container service, so its constructor is autowired; to change the realm, the header name or the HMAC tolerance, define the service with arguments in a provider.

HMAC request signing

For machine clients that should not hold a bearer token. The client signs "METHOD\n/path?query\n<unix timestamp>\n" . sha256(body) with HMAC-SHA256 and sends Authorization: HMAC <keyId>:<timestamp>:<hex signature>. The server accepts timestamps within $tolerance seconds (300 by default), which bounds replays; a replay with a different body, path or method fails because they are part of the signed string. Build the header with HmacAuthenticator::sign($keyId, $secret, $method, $target, $body, time()); the resulting principal is an Identity whose id is the key id. sign() refuses a key id that cannot travel in the header (empty, over 128 bytes, containing a colon or whitespace).

An identical request replayed inside the tolerance window is accepted unless the authenticator has a CounterStorage (its fourth constructor argument, autowired when one is registered, which is the case as soon as you configure rate limiting): every signature is then accepted once and a repeat answers 401 "Replayed signature". A client that retries after a lost response must sign again with a fresh timestamp.

A wrong signature and an unknown key id get the same answer, "Invalid signature", after the same amount of work, so key ids cannot be enumerated. Host and Content-Type are not part of the signed string: a signature is valid on every host that shares the secret, so never share HMAC secrets between environments.

JWT

JwtAuthenticator verifies JSON Web Tokens without a third-party library: HS256/384/512, RS256/384/512, ES256/384/512 and EdDSA (Ed25519); RSA-PSS is not supported.

// config/jwt.php: tokens of an identity provider (Auth0, Keycloak, Cognito, Entra ID, ...)
return static fn(Env $env): JwtConfig => new JwtConfig(
    algorithms: ['RS256'],
    jwksUrl: 'https://login.example.com/.well-known/jwks.json',
    issuer: 'https://login.example.com/',
    audience: 'orders-api',
    rolesClaim: 'realm_access.roles',
);

// tokens you issue yourself
return static fn(Env $env): JwtConfig => new JwtConfig(algorithms: ['HS256'], secret: $env->require('JWT_SECRET'), issuer: 'orders-api');
$token = Jwt::encode(['sub' => $user->id, 'roles' => $user->roles, 'exp' => time() + 900, 'iss' => 'orders-api'], $secret);

// config/auth.php
return new AuthConfig([JwtAuthenticator::class]);

A token is accepted when its alg is one of algorithms, a key of the matching type verifies the signature (with a kid only that key; an RSA key never verifies HS256, which defeats the algorithm-confusion attack; none is never accepted), it has no crit header, exp is in the future and nbf/iat not too far ahead (with leeway, 60 seconds by default), and iss, aud and requiredClaims match the config. Tokens without exp are refused unless requireExpiration: false. HMAC secrets must be at least 32 bytes, RSA keys 2048 bits.

The principal is an Identity: the id from subjectClaim (sub), the roles from rolesClaim (a dotted path; a list or a space-separated string), what #[Auth('admin')] checks, and every claim as an attribute, plus scopes as a list from scope or scp: $user->get('scopes'), $user->get('tenant'). A bad token is answered 401 with WWW-Authenticate: Bearer realm="api", error="invalid_token"; the reason (expired, wrong audience, unknown key id) is logged at info level, never told to the client.

With jwksUrl the keys are fetched through the PSR-18 client (load HttpClientProvider), kept in the worker for jwksTtl seconds (3600) and shared with the other workers through the PSR-16 cache when one is registered. A token with an unknown kid triggers a refetch, at most once a minute, so a rotation is picked up without letting forged key ids hammer the provider. When a refetch fails the known keys stay in use; without any, the request fails with a 500, not a 401. publicKeys (PEM, keyed by kid) and secret can be combined with jwksUrl. $authenticator->verifier()->verify($token) checks a token outside a request and returns its claims. keel:install jwt writes config/jwt.php and config/auth.php.

Requiring authentication

#[Auth] on a method or a class (every route of the controller) requires a principal; with roles, one holding any of them:

#[Delete('/users/{id}')]
#[Auth('admin', 'support')]
public function delete(int $id): void { /* ... */ }

The attribute adds AuthGuard to the route. The guard uses the principal AuthMiddleware left on the request, or authenticates by itself when that middleware is not in the pipeline; without a principal it answers 401 with a WWW-Authenticate header listing every scheme's challenge, with a principal lacking the roles 403. Both are Problem Details responses.

AuthMiddleware is optional global middleware (HttpConfig): it authenticates every request that carries credentials and attaches the principal, so public routes can personalise, and rejects invalid credentials everywhere, not only on protected routes.

Authentication is the service behind both: authenticate($request) runs the configured authenticators, principal($request) reads the attribute, unauthorized() builds the 401.

Policies

Roles answer "who are you"; policies answer "may you do this to that". A policy is a tagged service deciding named abilities for a principal and a subject:

#[Tag(Policy::TAG)]
final class PostPolicy implements Policy
{
    public function abilities(): array
    {
        return ['post.view', 'post.update', 'post.delete'];
    }

    public function allows(Principal $principal, string $ability, mixed $subject): bool
    {
        return match ($ability) {
            'post.view' => true,
            'post.update' => $subject instanceof Post && $subject->authorId === $principal->id(),
            'post.delete' => $principal->hasRole('admin'),
            default => false,
        };
    }
}

keel:make:policy Post post.update post.delete generates one. Discovery registers it through the tag; Gate collects every tagged policy and asks the one declaring the ability: allows($user, 'post.update', $post), denies(...), authorize(...) (throws 403 ForbiddenException on refusal) and abilities(). An ability no policy declares is a LogicException, a configuration error rather than a silent denial; two policies declaring the same ability are rejected when the gate is built.

In a handler, inject the Gate and the principal and check once the entity is loaded:

#[Patch('/posts/{id}')]
#[Auth]
public function update(int $id, Principal $user, Gate $gate, #[FromBody] UpdatePost $cmd): PostDto
{
    $post = $this->posts->get($id);
    $gate->authorize($user, 'post.update', $post);
    // ...
}

For a decision the route parameters suffice for, #[Can('post.update')] on the method or the class adds PolicyGuard: it authenticates like AuthGuard (401 without a principal) and asks the gate with the route parameters as the subject (['id' => '42']), answering 403 on refusal. keel:routes shows the ability in the Auth column; the OpenAPI document gets security plus 401 and 403, as with roles.

Rate limiting

#[RateLimit(limit, window, by)] allows limit requests per window seconds (60 by default) per client IP (by: 'ip', the default) or per authenticated principal (by: 'principal', falling back to the IP for anonymous requests):

#[Post('/search')]
#[RateLimit(30, 60)]
public function search(#[FromBody] Query $query): array { /* ... */ }

The attribute adds RateLimitGuard: every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds to the end of the window; the header names follow the earlier IETF draft, not the newer single structured RateLimit field); once exhausted the guard answers 429 with Retry-After. Windows are fixed and aligned to the clock (RateLimiter), so up to twice the limit can pass around a window boundary; the client IP is what TrustedProxiesMiddleware established, so forwarded headers only count from trusted proxies.

Counters are keyed by HTTP method, route pattern and subject (GET|/search|ip:203.0.113.5, GET|/mine|principal:u1), so two methods on one path and an IP that looks like a principal id never share a bucket. With by: 'principal' the guard authenticates the request itself when no principal was attached yet (no AuthMiddleware, or #[Auth] declared after #[RateLimit]): a request with credentials is always counted per principal, invalid credentials get 401, and only a truly anonymous request falls back to the IP.

Counters live in a CounterStorage: APCu when the extension is loaded (shared by every worker of the server), otherwise the memory of the current process. RedisCounters puts them on Redis for the whole fleet (RedisConfig(use: ['counters']), see Cache); register your own implementation under CounterStorage for anything else; InMemoryCounters suits tests. ApcuCounters fails closed: when APCu cannot store a counter (shared memory exhausted) the limit reads as exceeded rather than silently disabled, so size apc.shm_size for your key count.

Testing

TestClient sends headers like any client: $client->get('/me', ['Authorization' => 'Bearer t']) or $client->withHeader('X-Api-Key', 'k'). For HMAC use HmacAuthenticator::sign(). Register InMemoryCounters as the CounterStorage in tests that exercise rate limits.