Skip to main content
Production services eventually need to rotate signing secrets — whether because a key has been compromised, because your security policy enforces a maximum key age, or because a compliance framework demands it. The package supports zero-downtime rotation through a kid (key ID) map: you add a new key, promote it to active, and remove the old key only after every token signed under it has naturally expired.

Single-secret mode vs kid mode

The package supports two keyring shapes, and you choose between them based on whether you need graceful rotation. Single-secret mode uses one secret value. Every token is signed and verified with that secret, and no kid header is embedded in the token. This is the right choice for development, low-churn internal APIs, and any context where you can afford to invalidate all outstanding tokens during a rotation (for example, by restarting the service with a new secret). Kid mode uses a keys map of kid → secret pairs plus an active_kid pointer. New tokens are signed with the active key and carry its kid in the JWT header. The verifier accepts any kid present in the map, so old tokens remain valid while you drain traffic to the new key. Use kid mode whenever you need to rotate secrets without forcing every active session to re-authenticate.

Configuring kid rotation

Replace the single secret field in your authentication.php config (or in the per-guard jwt block inside config/auth.php) with a keys map and an active_kid pointer:
'jwt' => [
    'keys' => [
        '2026-04' => env('AUTHENTICATION_JWT_KEY_2026_04'),
        '2026-03' => env('AUTHENTICATION_JWT_KEY_2026_03'),
    ],
    'active_kid' => env('AUTHENTICATION_JWT_ACTIVE_KID', '2026-04'),
],
Add the corresponding environment variables:
AUTHENTICATION_JWT_KEY_2026_04="a-strong-random-value-of-at-least-32-bytes"
AUTHENTICATION_JWT_KEY_2026_03="the-previous-strong-random-value"
AUTHENTICATION_JWT_ACTIVE_KID="2026-04"
The keyring is fail-closed: if active_kid is absent from the keys map, or if any secret in the map is empty, the guard throws InvalidJwtConfigurationException at resolution time rather than silently accepting tokens.

How it works

When you configure kid mode, the package’s JwtKeyring operates as follows:
  • Issuing: every new token is signed with the key identified by active_kid and carries that kid in the JWT kid header field.
  • Verifying: the verifier reads the kid header from the incoming token, looks it up in the keys map, and verifies the signature using that key. Any kid present in the map is accepted — the verifier is not limited to the active kid.
  • Fail-closed: a token whose kid is not in the map is rejected immediately; the guard never falls back to a different key or to an empty-secret verification.
This means you can have two (or more) kids in the map simultaneously, and all corresponding tokens verify correctly for as long as their kid remains in the map.

Rotation procedure

1

Generate the new secret

Create a strong random secret for the new key and add it to your secrets manager or .env file:
openssl rand -base64 48
Set the new environment variable:
AUTHENTICATION_JWT_KEY_2026_05="<output-from-above>"
2

Add the new kid to the keys map

Add the new kid entry to authentication.jwt.keys (or the per-guard jwt.keys block) while keeping all existing entries in place. Deploy this change before promoting the new kid — both keys must be in the map and the service must be running before any token is signed with the new one:
'jwt' => [
    'keys' => [
        '2026-05' => env('AUTHENTICATION_JWT_KEY_2026_05'),
        '2026-04' => env('AUTHENTICATION_JWT_KEY_2026_04'),
        '2026-03' => env('AUTHENTICATION_JWT_KEY_2026_03'),
    ],
    'active_kid' => env('AUTHENTICATION_JWT_ACTIVE_KID', '2026-04'),
],
3

Promote the new kid to active

Update active_kid (and its environment variable) to point at the new kid. From this moment, all newly issued tokens are signed with 2026-05. Tokens signed under 2026-04 and 2026-03 continue to verify normally because both kids remain in the map:
AUTHENTICATION_JWT_ACTIVE_KID="2026-05"
4

Wait for old tokens to expire

Do not remove old kids from the map until every token signed under them has expired. Check your access_ttl_minutes and refresh_ttl_minutes settings — the safe wait period is the longer of the two. If you issue long-lived refresh tokens, wait for those to expire as well.
5

Remove expired kids from the map

Once no valid token can still carry the old kid, remove it from the keys map and clean up the corresponding environment variable:
'jwt' => [
    'keys' => [
        '2026-05' => env('AUTHENTICATION_JWT_KEY_2026_05'),
        '2026-04' => env('AUTHENTICATION_JWT_KEY_2026_04'),
    ],
    'active_kid' => env('AUTHENTICATION_JWT_ACTIVE_KID', '2026-05'),
],

Per-guard key isolation

Each JWT guard can carry its own independent jwt sub-block in config/auth.php, including its own keys map and active_kid. This lets you run fully separate signing-key lifecycles for different trust boundaries:
'guards' => [
    'staff' => [
        'driver'   => 'jwt',
        'provider' => 'users',
        'jwt'      => [
            'keys' => [
                '2026-05' => env('STAFF_JWT_KEY_2026_05'),
                '2026-04' => env('STAFF_JWT_KEY_2026_04'),
            ],
            'active_kid' => env('STAFF_JWT_ACTIVE_KID', '2026-05'),
            'audience'   => 'staff-api',
        ],
    ],
    'customer' => [
        'driver'   => 'jwt',
        'provider' => 'users',
        'jwt'      => [
            'keys' => [
                '2026-04' => env('CUSTOMER_JWT_KEY_2026_04'),
            ],
            'active_kid' => env('CUSTOMER_JWT_ACTIVE_KID', '2026-04'),
            'audience'   => 'customer-api',
        ],
    ],
],
A token minted for one guard’s audience cannot authenticate against another guard — the aud claim check rejects it before the key lookup even runs. Per-guard isolation means you can rotate the staff signing key without touching customer tokens, and vice versa.