---
title: "Configuration — JWT for Laravel | Roundly"
description: "Every config/jwt.php key with its env var and default — keys, pins, lifetimes, guard defaults, denylist and service tokens."
url: https://roundly-consulting.com/open-source/docs/jwt-for-laravel/configuration
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[JWT for Laravel](https://roundly-consulting.com/open-source/docs/jwt-for-laravel.md)

# Configuration

The published config/jwt.php is fully env-driven and works with zero host config for verification once the keys or secret are set. The file in full:

```php
use RoundlyConsulting\Jwt\UserTokens\TokenUser;

return [
    // Every value is read strictly: a key that is not set (absent, null or blank —
    // `KEY=`) takes its default, but a present value of the wrong shape (a TTL of 'five', a non-list `issuers`, a malformed
    // `secrets` pair, an `identity` that is not a ClaimsAuthenticatable) throws
    // JwtMisconfigured naming the key instead of silently falling back.

    // ── User tokens (RS256) ──────────────────────────────────────────────
    // `jwt:generate-keys` writes both. Only minting reads the private key, so a
    // verify-only app just never creates it. `.key` because a stock Laravel
    // `.gitignore` already excludes `/storage/*.key`.
    'private_key_path' => env('JWT_PRIVATE_KEY_PATH', storage_path('jwt-private.key')),
    'public_key_path' => env('JWT_PUBLIC_KEY_PATH', storage_path('jwt-public.pem')),
    'issuer' => env('JWT_ISSUER'),                    // required: minting/verifying throws when not set (null/blank)
    'audience' => env('JWT_AUDIENCE'),                // required: minting/verifying throws when not set (null/blank)
    'ttl' => env('JWT_TTL', 900),                     // access token seconds (≥1)
    'challenge_ttl' => env('JWT_CHALLENGE_TTL', 300), // 2fa_pending (≥1)
    'verify_ttl' => env('JWT_VERIFY_TTL', 3600),      // email_verify (≥1)
    'leeway' => env('JWT_LEEWAY', 10),                // clock-skew seconds (≥0)
    'kid' => env('JWT_KID'),                          // emit-only metadata when set

    // ── User guard ──────────────────────────────────────────────────────
    // Defaults for every `jwt` guard. With several jwt guards (e.g. `users`
    // and `clients`), each `auth.guards.<name>` array may override `audience`,
    // `scope`, `token_version`, `check_denylist` and `identity`; give each
    // guard its own `audience`, or one guard's tokens authenticate on another.
    'guard' => [
        'scope' => 'access',                              // required scope to authenticate
        'identity' => TokenUser::class,                   // claims-mode identity class
        'token_version' => null,                          // invokable-class|callable|null: fn(Authenticatable): int (closures break config:cache)
        'check_denylist' => env('JWT_CHECK_DENYLIST', true),
    ],

    // ── jti denylist ────────────────────────────────────────────────────
    'denylist' => [
        'store' => env('JWT_DENYLIST_STORE', 'redis'),
        'prefix' => env('JWT_DENYLIST_PREFIX', 'jwt:denylist:'),
    ],

    // ── Service tokens (HS256) ──────────────────────────────────────────
    'service' => [
        // Shared-secret mode: one ≥32-byte secret for the whole mesh
        // (`openssl rand -base64 48`). Any holder can then claim any `iss`.
        'secret' => env('SERVICE_JWT_SECRET'),
        // Per-issuer mode (recommended): "billing:<secret>,api:<secret>".
        // Binds each `iss` to its own secret; overrides `secret` when set.
        'secrets' => env('SERVICE_JWT_SECRETS'),
        // This service's own name: the `aud` every inbound service token must carry
        // (and the default `iss` below). Set it explicitly to a STABLE identifier —
        // unset, it falls back to a host's `app.service`, then to a slug of
        // `app.name`, which can change and silently re-pin every caller.
        'name' => env('JWT_SERVICE_NAME'),
        'issuer' => env('JWT_SERVICE_ISSUER', env('APP_SERVICE')), // unset ⇒ the service name
        'audience' => env('JWT_SERVICE_AUDIENCE'),
        'ttl' => env('SERVICE_JWT_TTL', 60),               // seconds (≥1)
        'issuers' => array_values(array_filter(array_map('trim', explode(',', (string) env('JWT_SERVICE_ISSUERS', ''))))), // allow-list; empty ⇒ any
    ],

    // ── Claim-based authorization ───────────────────────────────────────
    'authorize_from_claims' => env('JWT_AUTHORIZE_FROM_CLAIMS', false),
];
```

## Every key

| Key | Env | Default | Purpose |
| --- | --- | --- | --- |
| `private_key_path` | `JWT_PRIVATE_KEY_PATH` | storage\_path('jwt-private.key') | RSA private key path — read only when minting (issuer apps). |
| `public_key_path` | `JWT_PUBLIC_KEY_PATH` | storage\_path('jwt-public.pem') | RSA public key path used to verify user tokens. |
| `issuer` | `JWT_ISSUER` | null | Pinned iss — required; not set (null or blank) throws JwtMisconfigured. |
| `audience` | `JWT_AUDIENCE` | null | Pinned aud — required; not set (null or blank) throws JwtMisconfigured. The default for every jwt guard. |
| `ttl` | `JWT_TTL` | 900 | Access-token lifetime in seconds, at least 1. |
| `challenge_ttl` | `JWT_CHALLENGE_TTL` | 300 | 2fa\_pending token lifetime in seconds, at least 1. |
| `verify_ttl` | `JWT_VERIFY_TTL` | 3600 | email\_verify token lifetime in seconds, at least 1. |
| `leeway` | `JWT_LEEWAY` | 10 | Clock-skew tolerance in seconds for user and service tokens, 0 or more. |
| `kid` | `JWT_KID` | null | Emitted as a kid header when set — metadata only, never used to pick a key. A blank value reads as unset. |
| `guard.scope` | — | access | Scope a token must carry to authenticate on a jwt guard. |
| `guard.identity` | — | TokenUser::class | Claims-mode identity class; must implement ClaimsAuthenticatable, else JwtMisconfigured. |
| `guard.token_version` | — | null | Invokable class or closure returning the user’s current version, compared to tv. |
| `guard.check_denylist` | `JWT_CHECK_DENYLIST` | true | Enforce the jti denylist on jwt guards (an on/off switch). A blank JWT\_CHECK\_DENYLIST= is not set, so the denylist stays enforced. |
| `denylist.store` | `JWT_DENYLIST_STORE` | redis | Cache store backing the denylist; not set (null or blank) uses the default store. |
| `denylist.prefix` | `JWT_DENYLIST_PREFIX` | jwt:denylist: | Denylist cache-key prefix; a blank value is not set, so jwt:denylist: applies. |
| `service.secret` | `SERVICE_JWT_SECRET` | null | Shared HS256 secret, at least 32 random bytes. |
| `service.secrets` | `SERVICE_JWT_SECRETS` | null | Per-issuer secrets "billing:<secret>,api:<secret>"; overrides secret. A malformed pair, or an issuer named twice, throws JwtMisconfigured — never printing a secret. |
| `service.name` | `JWT_SERVICE_NAME` | app.service, else Str::slug(app.name) | This service’s own name — the aud inbound service tokens must carry. Set it to a stable id; the app name can change. |
| `service.issuer` | `JWT_SERVICE_ISSUER` | env('APP\_SERVICE'), else service.name | The iss of service tokens this app issues. |
| `service.audience` | `JWT_SERVICE_AUDIENCE` | null | Default aud when issue() gets no audience. |
| `service.ttl` | `SERVICE_JWT_TTL` | 60 | Service-token lifetime in seconds, at least 1. |
| `service.issuers` | `JWT_SERVICE_ISSUERS` | \[\] (any) | Comma-separated inbound issuer allow-list. |
| `authorize_from_claims` | `JWT_AUTHORIZE_FROM_CLAIMS` | false | Register the claim-based Gate::before hook (an on/off switch). |

## Strict values

The two on/off switches — guard.check\_denylist and authorize\_from\_claims — accept true/false, 1/0, on/off and yes/no, case-insensitive. Not set — absent, null or blank ('', what KEY= in .env gives) — reads as the default, so a blank JWT\_CHECK\_DENYLIST= keeps the denylist on; anything else, such as JWT\_AUTHORIZE\_FROM\_CLAIMS=disabled, throws JwtMisconfigured naming the key instead of quietly reading as on or off.

Every other value is read just as strictly, and a key that is not set (absent, null or blank) takes its default. The TTLs and leeway take an int or a canonical integer string, so JWT\_TTL=five, '1.5' or 0 throws JwtMisconfigured rather than becoming a 0-second token. A non-string path, kid, store, prefix or secret, a service.issuers that isn’t a list of strings, a malformed service.secrets pair and a guard.identity that isn’t a ClaimsAuthenticatable all throw too — none of them silently widens what the package accepts. A blank optional string (JWT\_KID=) is not set, so it reads as unset. A non-string service.secret or service.secrets — an array or int written into a published config file — is described by its type only (“must be a string or null, array given”), never its value, and is kept out of the stack trace’s call arguments.

## Service secrets

HS256 secrets must be at least 32 random bytes — a shorter, single-repeated-byte or PEM-shaped value raises ServiceAuthMisconfigured. Generate one with:

```bash
openssl rand -base64 48
```

## Per-guard overrides

The guard.\* keys and audience are the defaults for every jwt guard. Each guard can override audience, scope, token\_version, check\_denylist and identity in its own auth.guards.<name> array — see Multiple guards.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/jwt-for-laravel.md)
