Documentation Index
Fetch the complete documentation index at: https://docs.apivalk.com/llms.txt
Use this file to discover all available pages before exploring further.
AuthenticationMiddleware only knows how to extract Bearer <token> from the Authorization header. For anything else — an API key in X-Api-Key, a signed cookie, mTLS client cert — you have two options:
- Keep
AuthenticationMiddleware and implement a custom AuthenticatorInterface that is tolerant of your token format (fine if clients still send Authorization: Bearer <key>).
- Replace
AuthenticationMiddleware with a custom middleware that pulls the credential from wherever it lives, calls your authenticator, and sets the identity on the request.
This guide covers option 2 — because an X-Api-Key header isn’t a bearer token.
1. Define an identity class
You can reuse JwtAuthIdentity if you don’t mind the misleading name. For clarity, define a dedicated one:
<?php
declare(strict_types=1);
namespace App\Security;
use apivalk\apivalk\Security\AuthIdentity\AbstractAuthIdentity;
final class ApiKeyIdentity extends AbstractAuthIdentity
{
/** @var string */
private $accountId;
/** @var string[] */
private $scopes;
/** @var string[] */
private $permissions;
/**
* @param string[] $scopes
* @param string[] $permissions
*/
public function __construct(string $accountId, array $scopes, array $permissions = [])
{
$this->accountId = $accountId;
$this->scopes = $scopes;
$this->permissions = $permissions;
}
public function getAccountId(): string
{
return $this->accountId;
}
public function getScopes(): array
{
return $this->scopes;
}
public function getPermissions(): array
{
return $this->permissions;
}
public function isAuthenticated(): bool
{
return true;
}
}
2. Implement the authenticator
Keep credential validation isolated from HTTP plumbing. The authenticator takes a raw string and returns an identity or null.
<?php
declare(strict_types=1);
namespace App\Security;
use apivalk\apivalk\Security\AuthIdentity\AbstractAuthIdentity;
use apivalk\apivalk\Security\Authenticator\AuthenticatorInterface;
use App\Domain\ApiKey\ApiKeyRepository;
final class ApiKeyAuthenticator implements AuthenticatorInterface
{
/** @var ApiKeyRepository */
private $repository;
public function __construct(ApiKeyRepository $repository)
{
$this->repository = $repository;
}
public function authenticate(string $token): ?AbstractAuthIdentity
{
$record = $this->repository->findActiveByHash(\hash('sha256', $token));
if ($record === null) {
return null;
}
return new ApiKeyIdentity(
$record['account_id'],
$record['scopes'],
$record['permissions']
);
}
}
Store hashes, not raw keys. Compare in constant time if you can — a database index lookup by hash does that for you implicitly.
3. Write the middleware
This replaces AuthenticationMiddleware (don’t add both; you’d double up on work).
<?php
declare(strict_types=1);
namespace App\Security;
use apivalk\apivalk\Http\Controller\AbstractApivalkController;
use apivalk\apivalk\Http\Request\ApivalkRequestInterface;
use apivalk\apivalk\Http\Response\AbstractApivalkResponse;
use apivalk\apivalk\Middleware\MiddlewareInterface;
use apivalk\apivalk\Security\Authenticator\AuthenticatorInterface;
final class ApiKeyMiddleware implements MiddlewareInterface
{
/** @var AuthenticatorInterface */
private $authenticator;
public function __construct(AuthenticatorInterface $authenticator)
{
$this->authenticator = $authenticator;
}
public function process(
ApivalkRequestInterface $request,
AbstractApivalkController $controller,
callable $next
): AbstractApivalkResponse {
foreach (['X-Api-Key', 'x-api-key', 'X-API-KEY'] as $candidate) {
if ($request->header()->has($candidate)) {
$raw = (string)$request->header()->get($candidate)->getValue();
$identity = $this->authenticator->authenticate($raw);
if ($identity !== null) {
$request->setAuthIdentity($identity);
}
break;
}
}
return $next($request);
}
}
Notes:
- Passive, like
AuthenticationMiddleware. Invalid or missing keys don’t short-circuit — we let SecurityMiddleware decide based on the route’s RouteAuthorization. This keeps public routes public.
- Header name variants.
ParameterBag::has() is case-sensitive. If your web server normalises headers you may only need one variant, but covering three common casings is cheap insurance.
4. Register it in place of AuthenticationMiddleware
$authenticator = new ApiKeyAuthenticator($apiKeyRepository);
$configuration->getMiddlewareStack()->add(new ApiKeyMiddleware($authenticator));
$configuration->getMiddlewareStack()->add(new SecurityMiddleware());
5. Declare the security scheme for OpenAPI
Tell the OpenAPI generator that this API is secured via an apiKey in the X-Api-Key header. The name (ApiKeyAuth below) is what your routes reference.
use apivalk\apivalk\Documentation\OpenAPI\Object\ComponentsObject;
use apivalk\apivalk\Documentation\OpenAPI\Object\SecuritySchemeObject;
$components = new ComponentsObject();
$components->setSecuritySchemes([
SecuritySchemeObject::apiKey(
'ApiKeyAuth', // name — link to routes
'header', // in
'API key' // description
),
]);
Swagger UI will prompt the user for a key and send it on matching requests. See OpenAPI Generator / Security Schemes for the full mapping rules.
6. Protect routes
public static function getRoute(): Route
{
return Route::get('/api/v1/reports')
->routeAuthorization(
new RouteAuthorization('ApiKeyAuth', ['reports'], ['reports:read'])
);
}
Supporting both JWT and API keys
Run both middlewares. Whichever header the client sent populates the identity; SecurityMiddleware doesn’t care which authenticator was used, only whether the identity satisfies the route policy.
$configuration->getMiddlewareStack()->add(new AuthenticationMiddleware($jwtAuth)); // Bearer
$configuration->getMiddlewareStack()->add(new ApiKeyMiddleware($apiKeyAuth)); // X-Api-Key
$configuration->getMiddlewareStack()->add(new SecurityMiddleware());
Because both are passive, a request carrying neither credential still falls through as a guest — the route’s RouteAuthorization decides whether that’s allowed.
In your ComponentsObject, register both schemes (BearerAuth and ApiKeyAuth). Different routes can require different schemes; the SecuritySchemeObject.name passed to RouteAuthorization picks which one OpenAPI displays.
Rotating keys
Because the authenticator looks up keys by hash, revocation is “delete the row” and rotation is “insert a new row, tell the customer, delete the old row after a grace period”. Nothing is cached at the authenticator level — you rely on your database.
If you front the repository with a short-lived cache, remember to invalidate on rotation.