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
| 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 throws JwtMisconfigured. |
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.
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 48Per-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 cryptoBy 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.