> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sinemacula.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Rotate JWT Signing Keys Without Downtime

> Use kid-based JWT key rotation to introduce new signing keys, verify against a key map, and retire old keys once existing tokens expire.

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:

```php theme={null}
'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:

```bash theme={null}
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

<Steps>
  <Step title="Generate the new secret">
    Create a strong random secret for the new key and add it to your secrets manager or `.env` file:

    ```bash theme={null}
    openssl rand -base64 48
    ```

    Set the new environment variable:

    ```bash theme={null}
    AUTHENTICATION_JWT_KEY_2026_05="<output-from-above>"
    ```
  </Step>

  <Step title="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:

    ```php theme={null}
    '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'),
    ],
    ```
  </Step>

  <Step title="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:

    ```bash theme={null}
    AUTHENTICATION_JWT_ACTIVE_KID="2026-05"
    ```
  </Step>

  <Step title="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.
  </Step>

  <Step title="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:

    ```php theme={null}
    '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'),
    ],
    ```
  </Step>
</Steps>

## 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:

```php theme={null}
'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.
