NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
JWT for Laravel

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:

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

KeyEnvDefaultPurpose
private_key_pathJWT_PRIVATE_KEY_PATHstorage_path('jwt-private.key')RSA private key path — read only when minting (issuer apps).
public_key_pathJWT_PUBLIC_KEY_PATHstorage_path('jwt-public.pem')RSA public key path used to verify user tokens.
issuerJWT_ISSUERnullPinned iss — required; not set (null or blank) throws JwtMisconfigured.
audienceJWT_AUDIENCEnullPinned aud — required; not set (null or blank) throws JwtMisconfigured. The default for every jwt guard.
ttlJWT_TTL900Access-token lifetime in seconds, at least 1.
challenge_ttlJWT_CHALLENGE_TTL3002fa_pending token lifetime in seconds, at least 1.
verify_ttlJWT_VERIFY_TTL3600email_verify token lifetime in seconds, at least 1.
leewayJWT_LEEWAY10Clock-skew tolerance in seconds for user and service tokens, 0 or more.
kidJWT_KIDnullEmitted as a kid header when set — metadata only, never used to pick a key. A blank value reads as unset.
guard.scope—accessScope a token must carry to authenticate on a jwt guard.
guard.identity—TokenUser::classClaims-mode identity class; must implement ClaimsAuthenticatable, else JwtMisconfigured.
guard.token_version—nullInvokable class or closure returning the user’s current version, compared to tv.
guard.check_denylistJWT_CHECK_DENYLISTtrueEnforce the jti denylist on jwt guards (an on/off switch). A blank JWT_CHECK_DENYLIST= is not set, so the denylist stays enforced.
denylist.storeJWT_DENYLIST_STOREredisCache store backing the denylist; not set (null or blank) uses the default store.
denylist.prefixJWT_DENYLIST_PREFIXjwt:denylist:Denylist cache-key prefix; a blank value is not set, so jwt:denylist: applies.
service.secretSERVICE_JWT_SECRETnullShared HS256 secret, at least 32 random bytes.
service.secretsSERVICE_JWT_SECRETSnullPer-issuer secrets "billing:<secret>,api:<secret>"; overrides secret. A malformed pair throws JwtMisconfigured.
service.nameJWT_SERVICE_NAMEapp.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.issuerJWT_SERVICE_ISSUERenv('APP_SERVICE'), else service.nameThe iss of service tokens this app issues.
service.audienceJWT_SERVICE_AUDIENCEnullDefault aud when issue() gets no audience.
service.ttlSERVICE_JWT_TTL60Service-token lifetime in seconds, at least 1.
service.issuersJWT_SERVICE_ISSUERS[] (any)Comma-separated inbound issuer allow-list.
authorize_from_claimsJWT_AUTHORIZE_FROM_CLAIMSfalseRegister 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.

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:

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

By donating, you agree to our donation terms.

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.