API authentication

API clients, such as a mobile app or another server, can't use the login form, so they prove who they are with a token on every request. FloCMS supports personal access tokens, API keys, the admin session for your own JavaScript, and JWT.

Protecting routes

In a new project, the auth middleware accepts a personal access token or the admin session:

$router->get('/me', [ProfileController::class, 'show'])->middleware('auth');

$router->group('/users', function (Router $router): void {
    // ...
}, ['auth:users.manage']);
  • auth requires a signed-in user.
  • auth:users.manage also requires that permission. List several, separated by commas; the user needs all of them.

Permissions come from the same permissions setting as the admin panel. See roles and permissions.

Request Response
No token, or an invalid one 401 "Authentication token is required or invalid."
Valid token, missing permission 403 "You do not have permission to access this resource."

Personal access tokens

A token belongs to a user and acts with that user's role. The API tables are created by php flo migrate, or on their own with php flo api:install-schema. Then:

php flo api:token:create 1 "Mobile app"
php flo api:token:create 1 "Report script" --scopes=reports.read --expires=2027-01-01

The first argument is the user's ID. The token is printed once, so copy it then. Clients send it in the Authorization header:

curl -H "Authorization: Bearer flt_..." https://example.com/api/v1/users

Some hosts strip the Authorization header before PHP sees it. Clients can send X-FloCMS-Authorization: Bearer ... instead.

php flo api:token:list --user=1
php flo api:token:revoke 7          # one token
php flo api:token:revoke --user=1   # every token of user 1

Issuing tokens from code

To give users a token from your own login endpoint:

use FloCMS\Api\Auth\TokenRepository;
use FloCMS\Api\Database\Connection;

$tokens = new TokenRepository(new Connection());   // the site's database
$issued = $tokens->issueFor($userId, 'Mobile app', 30 * 86400, ['posts.write']);

return ['token' => $issued->plaintext];   // the only time it is visible

$tokens->revokeAllForUser($userId);       // for example after a password change

How tokens are stored

Tokens and API keys are random, shown once, and stored only as SHA-256 hashes in the api_tokens and api_keys tables. Verification takes constant time, and expiry and revocation are checked on every request.

A token's user is loaded through the same auth.user_loader as the admin panel. A deleted or suspended user's tokens stop working at once, and a role change applies to the next request. See authentication.

The signed-in user

Authentication middleware puts an Identity in the request's identity attribute:

public function store(Request $request): Response
{
    $identity = $request->attribute('identity');

    $userId = $identity->id;
    if ($identity->can('posts.publish')) {
        // ...
    }

    $identity->authorize('posts.write');   // 403 when not allowed
}

can() reads the permissions setting with the same wildcard rules as Auth::can().

The admin session from JavaScript

Admin pages can call /api/v1 with fetch() as the signed-in admin, without a token. The auth middleware accepts the session cookie when:

  • the user has admin access, reloaded from the database on every request
  • POST, PUT, PATCH and DELETE requests carry the X-CSRF-TOKEN header (419 otherwise)

The admin layout's csrf.js adds that header to same-origin fetch() and jQuery requests. See sessions and CSRF.

API keys

API keys are for servers rather than users, such as a partner's feed importer:

php flo api:key:create "Partner feed" --scopes=posts.read
php flo api:key:create "Sync job" --user=1 --expires=2027-06-30
php flo api:key:list
php flo api:key:revoke 3

Clients send X-API-Key: flk_.... A key without --user is a service key, with the identity api-key:<id>.

The skeleton's auth alias doesn't accept API keys. To use them, add an authenticator in public/api.php.

Authenticators

auth is built from AuthenticateMiddleware and authenticators. Build your own combinations in public/api.php:

use FloCMS\Api\Auth\{ApiKeyAuthenticator, ApiKeyRepository, BearerTokenAuthenticator, FirstMatchAuthenticator, TokenRepository};
use FloCMS\Api\Database\Connection;
use FloCMS\Api\Middleware\AuthenticateMiddleware;

$kernel->aliases([
    // ...the existing aliases
    'partner' => static fn (string ...$scopes): AuthenticateMiddleware => new AuthenticateMiddleware(
        new FirstMatchAuthenticator(
            new ApiKeyAuthenticator(new ApiKeyRepository(new Connection())),
            new BearerTokenAuthenticator(new TokenRepository(new Connection())),
        ),
        scopes: $scopes,
    ),
]);
$router->get('/feed', [FeedController::class, 'index'])->middleware('partner:posts.read');
Authenticator Credential Identity
BearerTokenAuthenticator Authorization: Bearer flt_<id>_<secret> The token's user
ApiKeyAuthenticator X-API-Key: flk_<id>_<secret> The key's user, or api-key:<id>
SessionAuthenticator The admin session cookie, plus X-CSRF-TOKEN The signed-in admin
JwtAuthenticator($secret) Authorization: Bearer <JWT> The token's sub claim
FirstMatchAuthenticator(...) Tries each authenticator in turn

AuthenticateMiddleware takes roles: (any of them), scopes: (all of them) and permissions: (all of them).

Custom authenticators

Implement FloCMS\Api\Contracts\AuthenticatorInterface to verify another credential. Return an Identity after verification, or null when the request has no valid credential. A header value alone is not proof of identity.

For a single trusted integration, create api/Auth/PartnerAuthenticator.php:

<?php
declare(strict_types=1);

namespace App\Api\Auth;

use FloCMS\Api\Contracts\AuthenticatorInterface;
use FloCMS\Api\Security\Identity;
use FloCMS\Core\Http\Request;
use InvalidArgumentException;

final class PartnerAuthenticator implements AuthenticatorInterface
{
    public function __construct(private readonly string $secret)
    {
        if (strlen($secret) < 32) {
            throw new InvalidArgumentException('Configure a partner token of at least 32 bytes.');
        }
    }

    public function authenticate(Request $request, string $guard = 'default'): ?Identity
    {
        $provided = $request->header('X-Partner-Token');
        if ($provided === null || !hash_equals($this->secret, $provided)) {
            return null;
        }

        return new Identity('partner', scopes: ['feed.read'], permissions: []);
    }
}

Set PARTNER_API_TOKEN to a generated secret in .env. Add an alias in public/api.php, retaining the existing aliases:

$partner = new \App\Api\Auth\PartnerAuthenticator((string) \FloCMS\Core\Env::get('PARTNER_API_TOKEN', ''));
$kernel->aliases([
    'partner' => static fn (string ...$scopes): AuthenticateMiddleware =>
        new AuthenticateMiddleware($partner, scopes: $scopes),
]);

Use ->middleware('partner:feed.read') on the intended routes. AuthenticateMiddleware calls authenticate() with its configured guard (default default), stores the returned identity in the request's identity attribute, and checks requested roles/scopes/permissions. An authenticator may use $guard to select a credential source; this example has one integration and ignores it.

For multiple users/partners, resolve credentials to records in your own repository rather than sharing this one fixed identity. See the container to inject that repository and API testing to test authorized and rejected requests.

JWT

JwtAuthenticator accepts HS256 tokens and rejects every other algorithm. Keep the secret in .env; a secret shorter than 32 bytes is refused. Issue tokens with Jwt::encode():

use FloCMS\Api\Auth\{Jwt, JwtAuthenticator};

$jwt = Jwt::encode(['sub' => $userId, 'exp' => time() + 3600], $secret);

$authenticator = new JwtAuthenticator($secret, issuer: 'https://example.com', audience: 'mobile');

exp, nbf and iat are checked with 30 seconds of leeway, and iss and aud when you pass them.

Esc