Configuration
The full configuration reference for both halves. The server is configured in config/lukk.php (published, env-driven); the client under the lukk key in nuxt.config.ts. Deep topics — asymmetric keys, transport modes, local-dev cookies — get the key here and a link to their dedicated page.
Server (Laravel)
After publishing the config with php artisan vendor:publish --tag=lukk-config, all options live in config/lukk.php. Every option has a default, and most are driven by environment variables so you can tune them per environment without editing the file.
Signing
'algorithm' => env('LUKK_ALGORITHM', 'HS256'),
'secret' => env('LUKK_SECRET'),| Key | Default | Description |
|---|---|---|
algorithm | HS256 | The JWS algorithm. Keep HS256 while this app is the sole verifier of its own tokens; switch to RS256/ES256 only when an independent service must verify them. |
secret | env('LUKK_SECRET') | The 256-bit HS256 signing key. Generate it with php artisan lukk:secret. Unused under an asymmetric algorithm. |
Asymmetric keys (RS256 / ES256)
Used only when algorithm is asymmetric. Generate a keypair with php artisan lukk:keygen (add --algorithm=ES256 for EC, --kid= to name the key). It writes nothing: it prints the private and public PEMs and the .env lines to set (LUKK_ALGORITHM, LUKK_ACTIVE_KID, LUKK_PRIVATE_KEY, LUKK_PUBLIC_KEY), and you store the keys yourself. Those feed the keys block:
'keys' => [
'active' => env('LUKK_ACTIVE_KID', 'default'), // the kid stamped on every token this app signs
'private' => env('LUKK_PRIVATE_KEY'), // signing key (issuer only)
'passphrase' => env('LUKK_KEY_PASSPHRASE'), // for an encrypted private key
'public' => array_filter([ // kid => verification key
env('LUKK_ACTIVE_KID', 'default') => env('LUKK_PUBLIC_KEY'),
]),
],Each key may be an inline PEM or a path to one (@/run/secrets/lukk.pem, or a bare path), so it can live in a secrets-mounted file rather than the environment. Under an asymmetric algorithm, GET {path}/jwks publishes the public keys as a JWK Set (RFC 7517). See Deployment → Asymmetric keys for the full block, key-rotation procedure, and the JWKS endpoint.
Issuer & audience
LUKK_ISSUER=https://api.example.com
LUKK_AUDIENCE=https://api.example.comThe iss and aud claims stamped into every token and validated on every request. Set both to your API's URL.
LUKK_AUDIENCE is comma-separated. To mint tokens for several services, list them all — LUKK_AUDIENCE=https://api.example.com,https://billing.example.com. The token then lists both, and each service accepts it when its own audience is in the list. A single audience is stamped as a plain string. See Deployment. Only the top-level LUKK_AUDIENCE is split on commas: an extra guard's audience in lukk.guards is taken as written, so list several there as a PHP array.
Token lifetimes
'access_ttl' => (int) env('LUKK_ACCESS_TTL', 900), // 15 minutes
'refresh_ttl' => (int) env('LUKK_REFRESH_TTL', 2592000), // 30 days| Key | Default | Description |
|---|---|---|
access_ttl | 900 (15 min) | Access-token lifetime, in seconds. Keep it short — revocation latency is bounded by this value. |
refresh_ttl | 2592000 (30 days) | The absolute session lifetime, in seconds. It is set at login and inherited by every rotation — it does not slide, so a session ends refresh_ttl after login regardless of activity, and the user must log in again. In cookie mode the refresh cookie's Max-Age follows it: from lukk 0.7.0 each rotation sets it to what is left of the session, not a fresh refresh_ttl. |
Refresh behavior
'grace_seconds' => (int) env('LUKK_GRACE', 30),
'claim_seconds' => (int) env('LUKK_CLAIM_SECONDS', 0),
'leeway' => (int) env('LUKK_LEEWAY', 5),| Key | Default | Description |
|---|---|---|
grace_seconds | 30 | The overlap window during which a just-rotated token is still tolerated, so concurrent refreshes (multiple tabs, SSR + hydration) do not trip reuse detection. Within this window the old token yields a full token pair — a sibling refresh token in the same session, which must be stored — see Authentication → Refreshing tokens. |
claim_seconds | 0 (off) | lukk 0.7.0. A session a sign-in starts must be claimed within this window — by POST /auth/session/claim, an authenticated request to this lukk app, or a refresh. If it isn't, the first late use of the credential issued at sign-in revokes the whole session and fires SessionUnclaimed. It ends sessions whose sign-in response never reached the client (a dropped connection, an aborted request), which otherwise stay alive until their refresh token expires. 600 suits most apps.The contract: every client must claim within the window. lukk-js calls the claim route right after each sign-in. A client that uses its access token only on another service never touches this app, so it must call the route too. Only the original sign-in credential is ever revoked: a token minted by a later refresh counts as a claim. For a refresh token that is exact — the family's first row, the one with no predecessor — while an access token, which carries no lineage, is matched on its iat within a couple of seconds of the sign-in. The window is never shorter than access_ttl + leeway, or 60 seconds. Sessions with a pinned grant (personal access tokens, impersonation) are never marked. A replacement RefreshTokenRepository must return original on its records (or, less precisely, createdAt); with neither, a late original refresh token isn't recognised (it fails open, with a warning logged once per worker process). Markers live in the denylist cache: losing them fails open, and restoring an old snapshot can at most revoke a session still presenting its original refresh token after the window. Costs one cache read per authenticated request when on, none when off. |
leeway | 5 | Clock-skew tolerance, in seconds, applied when validating the exp and nbf claims. Clamped to 0–300 (five minutes) from lukk 0.7.0 — RFC 7519 §4.1.4 allows "some small leeway" — so a larger value acts as 300. The clamp covers every token lukk decodes: access tokens, two-factor challenges, step-up confirmations and passkey challenges, which with a large LUKK_LEEWAY used to stay valid for up to a day past expiry. Fix the clock skew rather than widening it. |
Sign-in timebox
'login_timebox_micros' => (int) env('LUKK_LOGIN_TIMEBOX_MICROS', 500_000),lukk 0.7.0. How long a failed sign-in takes, in microseconds, whatever the reason it failed. Equal hashing work isn't equal time: an unknown identifier hashes at the configured cost, while a real account is checked at the cost stored in its hash, so a row left at a lower cost by a BCRYPT_ROUNDS bump answers measurably faster. lukk equalizes the work first (see Security), and this box hides what is left. A successful sign-in leaves the box at once — its status code already says it succeeded.
Set it above one derivation at your configured cost plus one at the oldest cost still stored, measured on your own hardware. This prints the microseconds one bcrypt derivation takes at each cost — run it with your configured cost and with the oldest cost still in your users table (the two digits after $2y$ in a stored hash), add the two, and leave headroom:
php -r 'foreach ([10, 12] as $c) { $t = hrtime(true); password_hash("x", PASSWORD_BCRYPT, ["cost" => $c]); printf("cost %d: %d µs\n", $c, (hrtime(true) - $t) / 1000); }'For Argon2 put your memory_cost/time_cost/threads into password_hash("x", PASSWORD_ARGON2ID, [...]) the same way. Too low is ineffective, not harmful: a timebox pads a fast path and never shortens a slow one. 0 switches it off. Read per guard.
The forgot-password route has its own box, password_reset.timebox_micros.
Rate limits
The configurable throttles live here, each shaped as { max_attempts, decay_seconds } (login adds ip_max_attempts and account_max_attempts), plus one scalar — ipv6_prefix — that applies to them all. Two more buckets exist without a key of their own; they borrow refresh and are described below the table.
'rate_limits' => [
'ipv6_prefix' => 64,
'login' => ['max_attempts' => 5, 'decay_seconds' => 60, 'ip_max_attempts' => 30, 'account_max_attempts' => 20],
'two_factor' => ['max_attempts' => 5, 'decay_seconds' => 60],
'refresh' => ['max_attempts' => 30, 'decay_seconds' => 60],
'passkeys' => ['max_attempts' => 30, 'decay_seconds' => 60],
'confirm' => ['max_attempts' => 5, 'decay_seconds' => 60],
'email_verification' => ['max_attempts' => 6, 'decay_seconds' => 60],
'password_reset' => ['max_attempts' => 6, 'decay_seconds' => 60],
'registration' => ['max_attempts' => 10, 'decay_seconds' => 60],
],Each value has an env var: LUKK_LOGIN_MAX_ATTEMPTS / LUKK_LOGIN_DECAY / LUKK_LOGIN_IP_MAX_ATTEMPTS / LUKK_LOGIN_ACCOUNT_MAX_ATTEMPTS, and LUKK_{2FA,REFRESH,PASSKEY,CONFIRM,VERIFY,RESET,REGISTER}_MAX_ATTEMPTS / …_DECAY for the others, in that order.
| Limit | Default | Keyed on | Notes |
|---|---|---|---|
login | 5 / 60s (+ ip_max_attempts 30, account_max_attempts 20) | folded identifier + IP | Failures-only: only failed attempts count, a success clears the per-address counter, and tripping it returns a 429 validation error. ip_max_attempts (env LUKK_LOGIN_IP_MAX_ATTEMPTS) is a separate coarse per-IP cap on all login attempts, bounding password-spraying across many emails. account_max_attempts (env LUKK_LOGIN_ACCOUNT_MAX_ATTEMPTS) is an IP-independent cap keyed on the identifier alone, so a botnet can't take max_attempts guesses per source IP against one account. Both buckets key on the case- and script-folded identifier only — never on the resolved account — so 429 and its Retry-After answer the same whether or not an account exists. From lukk 0.7.0 a success clears only the per-address bucket; the IP-independent one decays, so a look-alike address (another case, a Cyrillic а) that is a different account cannot sign in to reset this one's count. The fold also strips control, format and combining characters and trims Unicode whitespace (lukk 0.7.0), so it is never finer than the database's own equality — a zero-width or NUL-padded respelling that the lookup still resolves to the account doesn't buy a fresh bucket; sign-in refuses such identifiers anyway. An empty identifier — every attempt, when Lukk::authenticateUsing() reads a field other than username — keys no account bucket at all, and the per-address bucket is the address alone; point username at your field to get the account cap back. |
two_factor | 5 / 60s | account (sub) | Throttles challenge-code guesses for a single account. Each attempt is counted before its code is checked and refused on the new count (lukk 0.7.0), so a burst of concurrent guesses gets max_attempts and no more; a success clears it. Over the limit is a 429 on code. Also guards the endpoint per IP. |
refresh | 30 / 60s | IP | Per-IP guard on POST /auth/refresh. |
passkeys | 30 / 60s | IP | Per-IP guard on passkeys/login-options, passkeys/login and the step-up's confirm-passkey/options. |
confirm | 5 / 60s | account and IP | Guards step-up confirmation (confirm-password, confirm-passkey), change password (POST /auth/password) and the account export (GET /auth/account/export). Each re-checks the same secret as login, or hands out what a stolen token wants, so the per-user bucket is the load-bearing one — a stolen token is one identity behind any number of addresses. |
email_verification | 6 / 60s | IP (+ user on the resend) | The resend (email/verification-notification) and the signed verify link. The authenticated resend also gets a per-user bucket. |
password_reset | 6 / 60s | IP | forgot-password and reset-password. |
registration | 10 / 60s | IP | register. Deliberately no per-identity bucket: that would let anyone stop a chosen address from registering. |
login.* and two_factor.* are read per guard from lukk 0.7.0, like features.lockout and the lockout block — an extra guard that tightened them in its lukk.guards block silently got the global values before. And a key missing from a degraded config (a config:cache taken before the key existed is never backfilled) falls back to the value config/lukk.php ships, never to a looser generic default: up to 0.6 a missing confirm, email_verification or password_reset limit fell back to 30 per minute, six times the shipped value.
These bound a rate, not a run: the window keeps resetting, so account_max_attempts at 20/60s permits ~1,200 failures an hour indefinitely. The separate, opt-in account lockout is what caps consecutive failures (NIST SP 800-63B-4 §3.2.2).
Each maps to a named limiter you can also override with your own RateLimiter::for(): lukk-login (the per-IP route cap, ip_max_attempts), lukk-refresh, lukk-2fa, lukk-passkeys, lukk-confirm, lukk-email-verification, lukk-password-reset, lukk-register and lukk-claim. An extra guard gets its own lukk-{guard}-login, -refresh, -2fa, -confirm and -claim, read from its own block. The per-identifier login buckets and the two-factor per-account count run inside the action and have no limiter name. All of them count in Laravel's rate-limiter store (cache.limiter, else your default cache), not in denylist_store.
Throttles without a key of their own
Two buckets read rate_limits.refresh rather than owning a key, because each is at most one call per session and a second knob would be one more thing to get wrong. They are separate buckets, though — neither shares counters with POST /auth/refresh, so junk refresh traffic from one address can't refuse them.
- The logout refresh-token lookup (lukk 0.7.0), keyed per guard and per caller.
POST /auth/logouthas no route throttle; what is metered is the lookup of a presented refresh token, and only when it comes up empty or names an already-revoked family. A logout that ends a live session costs nothing, and one carrying a valid access token skips the bucket entirely. Exhausted, it answers429withRetry-Afterbefore looking anything up — see Authentication → Logging out. - The claim route (
lukk-claim, andlukk-{guard}-claimon an extra guard), which gatesPOST /auth/session/claim. Keyed per user, not per address: every call is already authenticated, and an address-keyed bucket is the one a NAT or a BFF shares. It falls back to the caller's address only when no user resolves.
What "keyed on IP" actually means. Every throttle buckets on Lukk::rateLimitKey(), which is the caller's address with IPv6 collapsed to ipv6_prefix (default /64, env LUKK_RATE_LIMIT_IPV6_PREFIX). It is clamped to 32–128, a non-numeric value means 64, and it is read from the global block only — one mask for every guard. A subscriber is typically handed a whole /64, so keying on the full address would let one visitor mint effectively unlimited buckets and walk through every per-IP limit. IPv4 is used as-is, and addresses that embed IPv4 (IPv4-mapped, NAT64's 64:ff9b::/96) are unwrapped rather than masked — otherwise a whole translated client population would share one counter. Raise it toward 128 if your users share a /64 (an office or campus LAN does); lower it if your attackers hold larger delegations.
Behind a BFF or reverse proxy that address is the proxy until the deployment forwards the real client — see clientIpHeader. Replace the identity entirely when the source address isn't the right bucket for you (a shared API gateway, a tenant, a CDN's own visitor token):
// A service provider's boot()
Lukk::rateLimitKeyUsing(fn (Request $request) => 'tenant-'.$request->user()?->tenant_id);The value must be something the caller cannot forge — it also buckets the login limiter, so a spoofable header would let an attacker mint a fresh bucket per request. It is used verbatim as part of a cache key, so namespace anything untrusted. Returning an empty value falls back to the address rather than silently putting every caller in one bucket.
Fork detection
'fork_threshold' => 3, // live tokens in one family before RefreshFamilyForked firesEnv: LUKK_FORK_THRESHOLD. Minimum 2.
The grace window mints a sibling for a concurrent refresh, so a family legitimately carries two or three live tokens. Above this, RefreshFamilyForked fires. Advisory only — see the event for why lukk doesn't act on it automatically.
Denylist
'denylist_store' => env('LUKK_DENYLIST_STORE'),The cache store backing the revocation denylist. null uses your application's default cache store. The denylist is self-evicting (entries expire with the tokens they revoke), so any cache driver works — Redis is recommended in production. Use a store that throws when unreachable (Redis, database): a denylist read error then propagates and access-token verification fails closed (rejects), rather than silently treating a revoked token as valid. Avoid a store that swallows connection errors into a null/miss.
IMPORTANT
Across multiple nodes this must be a shared, persistent store (e.g. Redis) — not the array driver and not a per-node cache. The same store also holds the TOTP replay markers, passkey challenges, spent two-factor challenge markers, the claim_seconds markers and the record of the longest access-token lifetime issued; if it isn't shared, a revoked token can still be honored on another node and replay protection isn't authoritative. (The rate limits are not here: they count in Laravel's rate-limiter store, which must be shared across nodes too.)
WARNING
lukk refuses an array or null cache store in production. The check runs when the denylist store is first resolved — on the first request that verifies a token or touches revocation, a TOTP replay marker or a passkey challenge — not at boot, so a misconfigured deploy boots and then throws a RuntimeException there. Token revocation, TOTP replay protection and passkey challenges all live here; an array store is per-process, so a revoked token stays valid on every other worker and the single-use guarantees stop being guarantees — silently. Outside production nothing changes, and the array driver stays the right default for a test suite.
Output mode
'cookie_mode' => (bool) env('LUKK_COOKIE_MODE', false),
'cookie' => [
'refresh_name' => '__Host-refresh',
'secure' => (bool) env('LUKK_COOKIE_SECURE', true),
],| Mode | Behavior |
|---|---|
false (default) | BFF mode. Both tokens are returned in the JSON body, for a server-side client (such as a Nuxt BFF) that seals them itself. |
true | Direct browser mode. The refresh token is set in a __Host-refresh cookie (HttpOnly, Secure, SameSite=Strict, Path=/, no Domain, Max-Age what is left of the session); only the access token and its expiry are in the body. The routes that set it — login, register, refresh, two-factor-challenge, passkeys/login — answer 415 unless the request is JSON or Sec-Fetch-Site: same-origin/none (why). |
cookie.secure (env LUKK_COOKIE_SECURE, default true) controls the refresh cookie's Secure attribute. Keep it true in production — the refresh token must never travel over plain http. Set it to false only for local development over http; lukk then also strips the __Host- prefix, which requires Secure. Never ship secure=false — see Local Development.
See Authentication → Output modes for the full response shapes, and Transport Modes for which client mode pairs with each (BFF ↔ body mode, direct ↔ cookie mode).
Guard & provider
'guard' => 'api',
'user_provider' => 'users',
'username' => env('LUKK_USERNAME', 'email'),
'guards' => [],| Key | Default | Description |
|---|---|---|
guard | api | The auth guard your app maps to the lukk-jwt driver. Used by the package's route middleware. An unset, null or empty value means api everywhere lukk reads it (lukk 0.7.0 — before, some readers took '' as the guard name). A numeric name is refused at boot. |
user_provider | users | The config/auth.php user provider used to resolve and validate credentials during login. It must read the same users as the provider the default guard resolves tokens through — auth.guards.<guard>.provider, or auth.defaults.provider when the guard names none — or the application refuses to boot, naming both (lukk 0.7.0): a password checked in one table would otherwise sign in as the row with the same id in the other. Two provider names over the same users boot: the same model under the eloquent driver, the same table and connection under database, or — for any other driver — identical auth.providers entries. |
username | email | The identifier column for login and registration (env LUKK_USERNAME). Must be a plain column name not containing password; unset, null or empty falls back to email. With app.debug on and the stock Eloquent provider, sign-in checks the column exists and throws naming the setting if it doesn't (lukk 0.7.0) — on SQLite a typo used to read as a string literal that matched every row. Production skips the check. See Registration → Username instead of email. |
guards | [] | Extra guards, each a name => [overrides] block — see Multiple Guards. false or '' mean none. A list of names, a numeric name or a truthy scalar is refused at boot with a message naming the problem (lukk 0.7.0), and so are an entry named after the default guard and a lukk-jwt guard in config/auth.php with no block here. |
Routes
'routes' => true,
'path' => 'auth',
'domain' => env('LUKK_DOMAIN'),| Key | Default | Description |
|---|---|---|
routes | true | Whether to register the package's built-in routes. Set to false to define your own. With false, lukk also leaves the email-verification and password-reset links alone: VerifyEmail and ResetPassword notifications carry Laravel's default URLs, not lukk's signed verify route or password_reset.frontend_url, so point them yourself (VerifyEmail::createUrlUsing(), ResetPassword::createUrlUsing()). |
path | auth | The URI prefix the routes are mounted under (e.g. /auth/login). A null value mounts at auth, not at the root (lukk 0.7.0). |
domain | null | Bind the routes to one host (e.g. api.example.com). Null serves them on any host. Each extra guard can set its own. |
Feature toggles
'features' => [
'logout_all' => true,
'two_factor' => false,
'lockout' => false,
'change_password' => true,
'account_deletion' => true,
'passkeys' => false,
'email_verification' => false,
'password_reset' => false,
'registration' => false,
'abilities' => false,
'gate_auth_routes' => true,
],Three of these default on and are worth knowing about: change_password and account_deletion add routes (account_deletion an irreversible one — see Account Deletion), and gate_auth_routes stops a pinned token reaching lukk's own session-management and account-security routes (account erasure and export are gated whatever it says). abilities only matters for an install whose grants come solely from pinned sessions; configuring Lukk::abilitiesUsing() turns the feature on by itself.
Rotation, reuse detection and the denylist are not switches: they are the security model. Releases up to 0.6 listed rotation, reuse_detection and denylist here, but never read them; 0.7.0 removes them, and a published config that still carries them keeps working.
| Feature | Default | Description |
|---|---|---|
logout_all | true | Mount DELETE /auth/sessions ("log out everywhere"). Honoured from lukk 0.7.0, per guard; earlier releases ignored it. false makes the route a 404. |
two_factor | false | Enable two-factor authentication. Requires pragmarx/google2fa. Honoured per guard. Only false, 0 or '0' stops enrolled accounts from being challenged; an unset or blank value fails closed. |
lockout | false | Enable the account lockout — the NIST SP 800-63B-4 §3.2.2 consecutive-failure cap. Requires the lukk-lockout-migrations migration. Honoured per guard from lukk 0.7.0. |
change_password | true | Enable change password (POST /auth/password). On by default — it needs no configuration. |
account_deletion | true | Mount account erasure and export (DELETE /auth/account, GET /auth/account/export), both behind step-up. On by default: erasure is a legal right (GDPR Art. 17). Turn it off where deletion is handled elsewhere. |
abilities | false | Mark the abilities feature as in use when grants come only from pinned sessions. Configuring Lukk::abilitiesUsing() turns it on by itself. |
gate_auth_routes | true | Refuse a pinned token lukk's own session-management and account-security routes unless its grant names them. Derived grants are never gated. |
passkeys | false | Enable passkeys. Requires a WebAuthn library. |
email_verification | false | Enable email verification. |
password_reset | false | Enable password reset. |
registration | false | Enable registration. |
NOTE
Rotation, reuse detection and the denylist are not in this table, and there is no switch for them: they are the security model, not a feature. The rotation / reuse_detection / denylist keys older releases listed were never read, and 0.7.0 removes them.
Two-factor
Used only when features.two_factor is enabled. See Two-Factor Authentication.
'two_factor' => [
'issuer' => env('LUKK_2FA_ISSUER'),
'window' => (int) env('LUKK_2FA_WINDOW', 1),
'recovery_codes' => (int) env('LUKK_2FA_RECOVERY_CODES', 8),
'challenge_ttl' => (int) env('LUKK_2FA_CHALLENGE_TTL', 300),
],| Key | Default | Description |
|---|---|---|
issuer | config('app.name') | The label shown in the authenticator app. |
window | 1 | Accepted clock drift, in 30-second steps (±1). Clamped to 0–10. Do not widen this — it multiplies brute-force odds. |
recovery_codes | 8 | How many recovery codes are generated. Clamped to 1–100. |
challenge_ttl | 300 (5 min) | How long a login challenge token is valid. Read per guard from lukk 0.7.0. |
Confirmation
Settings for step-up confirmation.
'confirm' => [
'ttl' => (int) env('LUKK_CONFIRM_TTL', 300),
'mfa_max_age' => (int) env('LUKK_CONFIRM_MFA_MAX_AGE', 86400),
'header' => env('LUKK_CONFIRM_HEADER', 'X-Lukk-Confirmation'),
],| Key | Default | Description |
|---|---|---|
ttl | 300 (5 min) | How long a confirmation ("sudo") proof remains valid. Read per guard from lukk 0.7.0. |
mfa_max_age | 86400 (1 day) | lukk 0.7.0. How long after a multi-factor sign-in (the token's auth_time) a password step-up is still accepted on an account with a second factor. NIST SP 800-63B-4 §2.2.3 allows one-factor reauthentication within an AAL2 session, whose overall limit SHOULD be about a day; mfa alone would vouch for the whole refresh_ttl. An auth_time more than leeway in the future is refused too, and so is a session with none — one signed in before lukk stored the time with the session — until its next multi-factor sign-in. See Confirmation → With a password. For "never", set a value as large as you like in the published config (PHP_INT_MAX, or a float past the integer range — compared as a float, so it can't overflow); then only the epoch bounds the allowance. A value that isn't numeric reads as 0, which switches the allowance off: every password step-up on such an account is refused. Read per guard. |
header | X-Lukk-Confirmation | The request header that carries the confirmation token. Must match the client's confirmationHeader. Read per guard from lukk 0.7.0 — the header of the guard that authenticated the request — so a guard with its own confirm.header needs its clients to send it there. |
Passkeys
Used only when features.passkeys is enabled. See Passkeys.
'passkeys' => [
'rp_name' => env('LUKK_PASSKEY_RP_NAME'),
'rp_id' => env('LUKK_PASSKEY_RP_ID'),
'origins' => array_values(array_filter(array_map('trim', explode(',', (string) env('LUKK_PASSKEY_ORIGINS', ''))))),
'challenge_ttl' => (int) env('LUKK_PASSKEY_CHALLENGE_TTL', 300),
'user_verification' => env('LUKK_PASSKEY_UV', 'required'),
],| Key | Default | Description |
|---|---|---|
rp_name | config('app.name') | The relying-party name shown in the OS passkey prompt. |
rp_id | required | The registrable domain shared by your front-end and API — e.g. example.com, not api.example.com. Throws if unset when passkeys are enabled. |
origins | required | Allowed browser origins (your front-end), as a comma-separated LUKK_PASSKEY_ORIGINS value. An empty list is rejected. |
challenge_ttl | 300 (5 min) | How long a WebAuthn ceremony may take. Sent to the browser as the options' timeout (in milliseconds); the server keeps the challenge 30 seconds longer, so an answer given at the last moment is still accepted in transit. lukk 0.7.0 raised the default from 120 to the WebAuthn L3 §15.1 recommendation for a user-verifying ceremony — why. With user_verification discouraged everywhere, WebAuthn suggests 120: a tap needs less time. |
user_verification | required | What a passkey login and a passkey registration ask the authenticator for: verify the user (biometric/PIN), not just their presence. Lower to preferred only for authenticators that can't verify the user. One of required, preferred, discouraged. From lukk 0.7.0 it no longer decides whether a login counts as multi-factor — that is read from each assertion (see why) — and a step-up sets its own requirement from the account's level. |
Lockout
Used only when features.lockout is enabled. See Account Lockout.
'lockout' => [
'max_attempts' => (int) env('LUKK_LOCKOUT_MAX_ATTEMPTS', 100),
'release_after' => (int) env('LUKK_LOCKOUT_RELEASE_AFTER', 0),
],| Key | Default | Description |
|---|---|---|
max_attempts | 100 | Consecutive failures before an account locks — a ceiling, not a target. |
release_after | 0 (never) | Seconds after which a lock lifts itself; 0 holds it until something releases it — a password reset or change, lukk:release. Choose deliberately. |
Email verification, password reset, registration
Used only when the matching feature is on; each page has the details.
| Key | Env | Default | Description |
|---|---|---|---|
email_verification.frontend_url | LUKK_VERIFY_URL | null | Where the verify link lands the browser (with ?verified=1). Empty answers 204 instead of redirecting. See Email Verification. |
email_verification.expire | LUKK_VERIFY_EXPIRE | 60 | The signed link's lifetime, in minutes. |
email_verification.block_unverified_login | LUKK_VERIFY_BLOCK_LOGIN | false | Refuse sign-in to an unverified user with 403, instead of gating routes with lukk.verified. Read per guard. |
password_reset.frontend_url | LUKK_RESET_URL | null | Your reset page; the emailed link appends ?token=…&email=…. Effectively required: empty emails a link with no host. See Password Reset. |
password_reset.revoke_sessions | LUKK_RESET_REVOKE_SESSIONS | true | Revoke every session on a successful reset. |
password_reset.revoke_passkeys | LUKK_RESET_REVOKE_PASSKEYS | false | Also remove every passkey (lukk 0.7.0). Only useful with revoke_sessions on. |
password_reset.timebox_micros | LUKK_RESET_TIMEBOX_MICROS | 1000000 | Wall-clock budget for every forgot-password request and every failed reset. |
password_reset.broker | LUKK_RESET_BROKER | null | The auth.passwords broker; null uses your default. |
registration.login | LUKK_REGISTER_LOGIN | true | Start a session on registration, or answer 201 and let the user sign in separately. See Registration. |
Console commands
| Command | Does |
|---|---|
lukk:secret | Writes LUKK_SECRET to .env — see Installation. --guard=admin writes LUKK_ADMIN_SECRET for an extra guard. |
lukk:keygen | Prints an RS256/ES256 keypair and the .env lines; writes nothing. |
lukk:prune | Deletes expired refresh tokens, spent lockout counters and orphaned passkeys; scheduled daily — see Deployment. |
lukk:release | Lifts an account lockout (--purpose=, --guard=). |
Client (Nuxt)
Everything is configured under the lukk key in nuxt.config.ts:
| Option | Type | Default | Purpose |
|---|---|---|---|
baseURL | string | '' | Your lukk auth URL, including the route prefix. |
mode | 'bff' | 'direct' | 'bff' | Transport mode — see Transport Modes. |
ssrHydrate | bool | true | BFF-only — hydrate user/loggedIn during SSR (no flash). |
user.endpoint | string | '' | Your app's authenticated user route (per-mode). |
user.key | string | false | 'data' | The wrapper key to unwrap from the user response — see The User. |
api.path / api.target / api.forceJson / api.forwardSetCookie | string / string / bool / string[] | '' / '' / true / [] | BFF-only app-API proxy. |
session.password | string | env | BFF sealed-session secret (≥ 32 chars). |
session.cookieSecure | bool | auto | BFF session cookie Secure/__Host- — see Local Development. |
session.name | string | — | BFF session-cookie namespace, so co-hosted apps don't collide. |
session.sharedStore | string | — | BFF: a Nitro storage mount every instance shares, for the record of replaced sessions. |
session.maxAge | number (seconds) | 2592000 (30 days) | BFF: the lifetime stamped into every seal — a positive whole number, or the build fails. |
bodyLimit | number (bytes) | 1048576 (1 MiB) | BFF: the largest request body the auth proxy reads. |
confirmationHeader | string | 'X-Lukk-Confirmation' | Header carrying the step-up token. |
clientIpHeader | string | '' | BFF-only, opt-in — forward the real visitor IP upstream. |
storage | 'cookie' | 'cookie' | Reserved — any other value fails the build. |
export default defineNuxtConfig({
modules: ['lukk-nuxt'],
lukk: {
baseURL: 'https://api.example.com/auth',
mode: 'bff',
user: { endpoint: '/api/me' },
confirmationHeader: 'X-Lukk-Confirmation',
},
})The nested options (api, session, user) are optional field by field (lukk-nuxt 0.12.0): their defaults fill whatever you leave out, so api: { path: '/api', target: '…' } type-checks without forceJson and forwardSetCookie.
The module also warns at build — without failing — when only one of api.path / api.target is set (the app-API proxy is then not registered), when clientIpHeader is set in direct mode (it has no effect there) or names an append-style header, when session.name isn't a slug, when BFF mode has no session password, and when session.cookieSecure is false in a production build.
baseURL
The fully-qualified URL of your lukk auth routes, including lukk's route prefix (lukk.path, default auth):
baseURL: 'https://api.example.com/auth'In bff mode this is read only on the server and is never shipped to the browser. In direct mode it is part of the public runtime config, because the browser calls lukk directly — so it must be reachable from the browser and CORS-configured on lukk.
It is validated at build time. A baseURL that isn't a valid absolute URL fails nuxt build with the offending value in the message (nuxt prepare, which a starter's postinstall runs before any secret exists, only logs the error), rather than building an app that answers 400 on every auth call. The check requires an http:// or https:// scheme — note that localhost:3000/auth is parseable (as a localhost: scheme) but is not a usable base, so it's rejected too.
WARNING
The fault this catches is an unset build-time environment variable. A config like baseURL: `${process.env.API_URL}/auth` interpolates an undefined variable into the string "undefined/auth" — non-empty, so it used to build fine and then fail at runtime. Make sure the variable is present wherever you run nuxt build; a nuxt prepare without it (a CI postinstall step, say) logs the same error but carries on.
In direct mode a root-relative base (/auth) is also accepted: the browser resolves it against the current origin, which is the right setup when lukk is served from the same origin as your app. A server-fetched base (bff mode) has no valid relative form.
CAUTION
A root-relative base is browser-only. Node has no page to resolve it against, so calling lukk during SSR with it — e.g. useAsyncData(() => useLukkAuth().fetchUser()) — fails to parse the URL. lukk itself never fetches this base on the server, so ordinary use is fine; just don't drive lukk calls from server-rendered data fetching. Use an absolute URL if you need that.
NOTE
If baseURL is empty the module logs a warning at build time — "not configured yet" is treated differently from "configured wrong". It is the one option you always set.
A base carrying a query or fragment (https://api.example.com/auth?tenant=1) is rejected as well: the request path is appended after it, so /login would resolve to /auth?tenant=1/login — every route would silently hit the same upstream endpoint, including /logout.
There is also a build-time warning (not an error, because only you can tell it apart from a deliberate setup) when a production build points at loopback (http://localhost:…, 127.0.0.1) — legitimate when you're testing a production build locally, but also exactly what a dev .env leaking into a real deploy looks like.
TIP
If you build once and deploy to many environments, don't bake a template string into baseURL. Leave it empty (that only warns) and supply the override for your mode at runtime: NUXT_LUKK_BASE_URL in bff mode, or NUXT_PUBLIC_LUKK_BASE_URL in direct mode — the browser reads the public copy, so the private one alone would leave it empty. See environment variables.
One caveat in direct mode: the useLukkFetch() app-API base is derived at build time, so a runtime baseURL override doesn't feed it. Set api.target explicitly if you use it.
mode
mode: 'bff' // or 'direct'bff(default) — a Nitro proxy holds tokens server-side; the browser never sees one.direct— the client calls lukk directly; the access token lives in memory.
This is the single switch that changes the transport. Your component code does not change. Read Transport Modes before choosing, and pair it with lukk's output mode on the server (direct ↔ cookie mode, bff ↔ body mode).
ssrHydrate
ssrHydrate: true // default; BFF onlyIn BFF mode the server reads the sealed session and seeds useLukkAuth().user / loggedIn during server rendering, so authenticated pages render logged-in on the first paint — no logged-out→logged-in flash and no <ClientOnly>. Only the app user resource enters the SSR payload (never a token), and a hydrated render is marked Cache-Control: no-store. See Transport Modes for the full rationale.
Set false to have the client resolve the user instead. The server still renews an aged-out session once before the render — so the render's own app-API requests carry a live token, and a render never rotates the session mid-page — without loading the user, and still marks a render carrying a session no-store. A page load after the access token aged out then rotates twice (the server's renewal, then the client's restore), and both count against lukk's refresh throttle. No effect in direct mode (there's no server-side session to read).
NOTE
Enabling this (the default) means SSR user is now populated in BFF mode where it was previously null until client hydration. Review any page that assumed the server always renders anonymous.
NOTE
Not on a route behind a swr/isr/cache rule. Nitro hands a cached handler a request cloned with only the rule's varies headers, so with the default varies that render never sees the session cookie: the HTML is anonymous, and the client restores after hydration. It also means ssrHydrate has no effect on those routes. Add the cookie to the rule (cache: { varies: ['cookie'] }) if you need the user in the cached HTML — the whole Cookie header then joins the cache key, so entries fragment per browser rather than per session, and a per-user render is held in the server-side cache for the rule's window. See Transport Modes → SSR hydration.
user.endpoint
user: { endpoint: '/api/me' }A route on your backend that returns the authenticated user, used to populate useLukkAuth().user (unset → user stays null). It is mode-dependent:
direct— a path or absolute URL; the access token is attached as aBearerheader.bff— the browser has no token, so this must be a same-origin path authenticated server-side: a path under theapiproxy (e.g./api/me), or your own route usinggetLukkAccessToken(event). No header is attached client-side. Write it without the app's base: it resolves underapp.baseURL, like the proxy mount it points at (lukk-nuxt 0.12.0 — earlier, an app served under/admin/fetched its user from the origin root). If you worked around that with a base-prefixed relative endpoint, drop the prefix: underapp.baseURL: '/admin/','/admin/api/me'now resolves to/admin/admin/api/me, which404s and leaves everyone signed out on load — write'/api/me'. An absolute endpoint keeps working.
Response shaping (user.key), typing (LukkUser), and the verified state are covered on The User.
api (BFF app-API proxy)
api: { path: '/api', target: 'https://api.example.com', forceJson: true }BFF-only and opt-in. Forwards ${path}/** to the fixed target (your Laravel API), injecting the access token server-side — so the browser authenticates to your own API without ever holding a token. target is never derived from the request (SSRF-safe); non-GET requests with a foreign Origin are rejected (CSRF), and from lukk-nuxt 0.12.0 so is a cross-site or same-site GET that isn't a top-level navigation (Sec-Fetch-Dest: document), as on the auth proxy; the inbound Cookie/Authorization + spoofable X-Forwarded-* and Origin are removed (before 0.12.0 they reached the upstream present but empty); upstream Set-Cookie, Access-Control-* and hop-by-hop response headers are stripped (the last two from lukk-nuxt 0.12.0, so the API's CORS policy never decides who reads your app's origin); and /api/_lukk/** is never proxied.
- Step-up tokens are injected server-side. A route behind lukk's
lukk.confirmmiddleware works through this proxy once the user has confirmed: the token comes from the sealed session, and one sent by the browser is discarded. Rename it withconfirmationHeaderand both proxies follow — the value must be a valid HTTP header name or the build fails. targetis validated at build time exactly likebaseURL— it must be an absolutehttp(s)URL when the proxy is registered. (Indirectmodetargetisn't proxied but still becomes the app-API base foruseLukkFetch(), so it's validated there too, where a same-origin/apiis legitimate. There it is used as written — a relative/apiis an API served beside the app on the same origin, not a route under itsapp.baseURL, so from lukk-nuxt 0.12.0 it is no longer prefixed with the base.)forceJson(defaulttrue) setsAccept: application/jsonon forwarded requests so a JSON API renders clean401/422JSON for unauthenticated/validation errors — instead of Laravel's default guest-redirect, which 500s behind a proxy. Setfalseto forward the browser'sAcceptinstead — only if a route underpathlegitimately serves a non-JSON response.- lukk's own routes are refused. In the usual layout
api.targetis your Laravel app andbaseURLis the same app under/auth, so/api/auth/loginwould have proxied a sign-in and streamed its{ access_token, refresh_token }to the browser — exactly what BFF mode exists to prevent. From lukk-nuxt 0.12.0 a request whose upstream URL falls under lukk's base path answers404, decided on the URL the way Laravel's router reads it (dot segments collapsed, trailing slash trimmed, repeated slashes merged,%2Fand other escapes decoded, case-insensitively) and regardless of host — and again after each of up to four further rounds of percent-decoding, so/api/x/%252e%252e/auth/loginor/api/%2561uth/loginis refused too, in case a CDN, rewrite rule or second proxy in front of lukk decodes once more (a path still decoding to something new after four rounds is refused outright; "new" is judged on the path as the URL parser re-reads it, so an escape it simply puts back —%20,%22,%7B— settles at once, and/api/files/Annual%20Report.pdfis proxied) — since the same app is often named publicly inapi.targetand internally inbaseURL. A?or#that a round decodes is read both ways a hop might — cutting the path there, and keeping it as path data — so/api/x%3F/%252e%252e/auth/loginis refused as well. And because a chain of hops (merge slashes, then decode, then collapse) can land where no single reading does, any path in which decoding reveals a whole.or..segment is refused outright, wherever it would lead: one encoded twice (/api/x/%252e%252e/y), one behind an encoded separator h3 leaves encoded (/api/files/a%2F..%2Fb,/api/a%5c..%5cb), and one a tab, LF or CR splits (.%2509., which the URL parser strips before it collapses dots). A dot inside a segment name —v1.2,.well-known,..y— is unaffected. Call lukk through the composables (the/api/_lukkauth proxy) instead. If lukk is mounted at the root ofapi.target's host there is no path that tells the two apart, so the proxy refuses everything — move lukk under a prefix or onto its own host. A PHP front controller in the path is seen through too: Laravel routes/index.php/auth/loginlike/auth/login, and Symfony strips the script's name as a leading substring with no/required after it, so/index.phpauth/loginroutes there as well. Every.phpis therefore read as the end of a script's name —/api/index.php/auth/login,/api/index.phpauth/login, an app under a sub-path (/app/index.php/auth/login), one only a further decoding hop reveals (/index.php%2Fauth/login) — and what follows it is compared, in the request path and in abaseURLthat names the script itself (https://host/index.php/auth) alike, and the path is judged as written as well, so such a segment never hides an encoded separator or... A script in a subdirectory is read with everything before it (/api/public/index.php/auth/login), and because Symfony's last resort cuts a path by the length of the deploy'sSCRIPT_NAME, any path that names a.phpand contains lukk's last base segment (auth) whole anywhere is refused —/api/oauth/callback.phpincluded, which never reaches lukk (details) — and so is any path naming more than four.phpin any decoding round, which keeps the check linear in the path. What no path check can see is a front controller no.phpin the URL names: shared hosting whose rewrite sends/public/auth/loginintopublic/index.php. Nor does it know an extra guard's mount (admin/auth) on the same host. The robust layout is anapi.target— host or prefix — that doesn't serve lukk's routes at all. An unresolvablebaseURLorapi.targetfails the app-API proxy closed with a500naming the setting at fault. - A page render's own requests never rotate the session (lukk-nuxt 0.12.0). A BFF server plugin marks the page request (
x-lukk-ssr, removed before anything reaches your API), so every request the render makes —useLukkFetch, and Nuxt's ownuseFetch('/api/…'),useRequestFetch()andevent.$fetchalike — carries it, and the proxy forwards it with the token as it is rather than renewing an expired one: a cookie rotated there would go back to the render, never to the browser, which would then replay the consumed token. The render renews once, before it starts (ssrHydrate). A server route's ownevent.$fetch('/api/…')isn't a render and isn't marked; read the session there withgetLukkAccessToken(event). - A path past 8 KiB answers
414(lukk-nuxt 0.12.0), measured on the upstream URL as it serialises (query aside, a{or"counting three as%7B/%22), before the path is judged — nginx's and Apache's default request-line limit. The auth proxy applies the same limit. - The request target is forwarded as sent. The query comes from the raw request, and an encoded
?or#in a path segment (/files/a%3Fb) stays encoded rather than becoming a real query or fragment upstream. Responses carryCache-Control: private, no-storeandVary: Cookie, merged with the upstream's ownVary. - Responses can't run as your app (lukk-nuxt 0.12.0). They're served on the app's origin, so every one carries
X-Content-Type-Options: nosniff, and one whoseContent-Typeis neither JSON (application/json,application/*+json) norapplication/pdfalso carriesContent-Security-Policy: sandbox, added to the upstream's own policy rather than over it. An HTML or SVG document from the API then renders with an opaque origin and no script. JSON is left alone because no browser renders it as a document of this origin (and Firefox's JSON viewer breaks under a sandbox), a PDF because Chromium refuses to show one sandboxed at all. Serve documents that need script from another origin. forwardSetCookie(default[]) is an allow-list of cookie names to pass through from the app API to the browser; everything else is stripped. No lukk session cookie is ever forwardable — not this app's, nor a co-hosted app's — whatever the list says. For a hybrid app whose Laravel API sets its own cookie (a locale, a theme) — see Transport Modes.
Request headers pass through. Headers the proxy does not explicitly strip or overwrite reach your API unchanged — this is intentional and covered by tests, so you can depend on it. It's what lets an app carry per-visitor data across the proxy hop that the hop would otherwise destroy: a Nitro middleware can copy Cloudflare's CF-IPCountry / CF-IPCity onto your own X-Visitor-* headers (a prefix Cloudflare won't re-stamp) and read them on the Laravel side, since the proxy's own connection is re-stamped with the server's location. What the proxy replaces or removes: Cookie, Authorization, the step-up confirmation header, Origin, X-Forwarded-For, the spoofable forwarding set (X-Real-IP, CF-Connecting-IP, Forwarded, …), the path/scheme family (X-Forwarded-Prefix, -Scheme, -SSL, -URI, X-Original-URL, X-Rewrite-URL, stripped from lukk-nuxt 0.12.0 — Symfony/Laravel apply a trusted X-Forwarded-Prefix to every generated URL) and anything the client named in Connection (RFC 9110 §7.6.1) — all credentials, client-identity or request-location claims a browser must not assert — and Accept while api.forceJson is on (set to application/json). With forceJson off, the browser's Accept is forwarded as sent — an empty one included — and none is added when it sent none. Those others are removed outright, not forwarded empty; a header the browser itself sent with an empty value is not one of them and is forwarded as sent (RFC 9110 §5.5 allows an empty field value). A handful of hop-by-hop headers (connection, transfer-encoding, accept-encoding, upgrade, expect, host) are dropped by the proxy layer itself and don't transit either.
TIP
Call the proxied API with useLukkFetch() — a plain $fetch forwards no cookie during SSR and silently 401s. It also rejects with a typed LukkError ({ message, status, errors }).
session.password
The secret that seals the BFF token cookie (≥ 32 characters). Set it via the environment, not in nuxt.config.ts:
NUXT_LUKK_SESSION_PASSWORD=a-long-random-string-of-at-least-32-charsOnly used in bff mode. Treat it like Laravel's APP_KEY: secret, and rotating it logs everyone out.
session.name
Namespaces the BFF sealed-session cookie so multiple lukk apps can share a host without clobbering each other's session. Cookies are scoped by host, not port, so two apps on localhost:3000 + :3001 (or two apps under one domain via path routing) otherwise read and overwrite the same cookie — logging into one silently logs the other out. Set a distinct slug ([A-Za-z0-9._-]) per app:
lukk: { session: { name: 'admin' } }Three cookies carry the namespace — the sealed session, and the two that drive a logout:
| Cookie | session.name | Secure (prod / --https) | Dev over http |
|---|---|---|---|
| Sealed session | unset | __Host-lukk-session | lukk-session |
| Sealed session | 'admin' | __Host-lukk-admin-session | lukk-admin-session |
| Logout note | unset | __Host-lukk-logout | lukk-logout |
| Logout note | 'admin' | __Host-lukk-admin-logout | lukk-admin-logout |
| Signed-out answer | unset | __Host-lukk-signed-out | lukk-signed-out |
| Signed-out answer | 'admin' | __Host-lukk-admin-signed-out | lukk-admin-signed-out |
Unset keeps the default names, so adding it to one app doesn't change the other. The cookies are bff-only, but the name is read in both modes: it also scopes the cross-tab lock, the broadcast channel and direct mode's per-tab notes. The logout note is written by the browser the moment logout() is called and lives a minute; the signed-out answer is the server's reply to it and lives ten seconds. Both are Path=/, SameSite=Strict, and Secure exactly where the name carries __Host-. Neither is HttpOnly — the browser writes and reads the first, and reads the second. The signed-out answer is a bare marker; its presence is the whole message. The note carries one value: the moment logout() was called, which the page that finishes it cannot otherwise know. Their names reach the browser as runtimeConfig.public.lukk.logoutCookie and runtimeConfig.public.lukk.signedOutCookie. From lukk-nuxt 0.12.0 the server restates both in each request's public config before anything renders, so a runtime cookieSecure override needs nothing else; earlier releases baked them in at build, and the browser then wrote a note the server never read unless you overrode both by hand.
WARNING
Co-hosted apps need a distinct session.name. Everything that keeps two apps on one origin apart is named from it: the cookies, and — together with app.baseURL — the cross-tab Web Lock, the BroadcastChannel and the direct-mode web-storage keys. Leave both the same and one app's pending-logout note is read by the other, which then ends a session it doesn't own, while their tabs queue behind each other's lock for no reason.
WARNING
session.name is de-confliction, not a trust boundary. Apps that share an origin — the same host with path routing, or localhost across ports — share one cookie jar, and the namespace only keeps their cookies from overwriting one another. The real isolation is the per-app session.password (the seal): a co-hosted app can't decrypt or forge another app's session without its password. For apps in distinct trust domains, put them on separate subdomains — where the __Host- prefix plus the proxy's Origin check give real isolation — and give each a distinct, strong session.password.
session.sharedStore
The BFF remembers, for ten minutes, the sessions a sign-in replaced or a logout ended, so a refresh still in flight for one can't write it back into the browser (see Authentication). By default that record lives in each server process. Behind a load balancer without sticky sessions — or on serverless and edge runtimes, where every instance or isolate is its own process — a late request served elsewhere doesn't see it.
Name a Nitro storage mount that every instance shares, and the record is written there too:
export default defineNuxtConfig({
nitro: { storage: { 'lukk-sessions': { driver: 'redis', url: process.env.REDIS_URL } } },
lukk: { session: { sharedStore: 'lukk-sessions' } },
})Use a strongly consistent driver that expires keys itself — Redis, or Upstash / Vercel KV. Not Cloudflare KV: a write can take up to a minute to show on another location, and the window this record guards is under a second. Each entry also carries its own expiry, so a driver without TTL still answers correctly, but never deletes anything.
Each store call is capped at 300 ms, and after a failure the store is left alone for 30 seconds — a slow or unreachable store falls back to the per-process record instead of holding up sign-ins, logouts and page loads. lukk-nuxt reports that once per server process, and warns at startup when the mount name matches no configured nitro.storage mount.
sharedStore holds only that record — it is not a token store. The BFF's rotation journal stays per process whatever you set here — use sticky sessions behind a load balancer.
With Redis, also tell the client not to queue and retry commands while the connection is down, so each call fails fast instead of running into that cap every time. (The client still logs its own reconnect attempts, [ioredis] Unhandled error event, for as long as the outage lasts.)
nitro: {
storage: {
'lukk-sessions': { driver: 'redis', url: process.env.REDIS_URL, enableOfflineQueue: false, maxRetriesPerRequest: 1 },
},
},session.maxAge
lukk: { session: { maxAge: 2592000 } } // seconds; default 30 dayslukk-nuxt 0.12.0. The lifetime, in seconds, of each sealed session the BFF writes — and of the session cookie that carries it, which now has a matching Max-Age restarted on every write (before, it had none, so BFF users were signed out whenever the browser closed). A cleared session's cookie is deleted outright. iron stamps it into the seal and checks it on every unseal, so a session cookie copied out of a browser stops unsealing that long after it was written; an expired seal reads as no session. Every refresh re-seals, so an active session is still bounded by lukk's refresh family. The default matches lukk's default refresh_ttl — keep maxAge at least that long, or idle sessions end before their refresh token does. Seals written by earlier versions carry no expiry until they are next re-sealed.
It must be a positive whole number of seconds (lukk-nuxt 0.12.0). iron reads a ttl of 0 as "never expires", so 0 sealed every session with no expiry at all; a negative Max-Age deletes the cookie it sets; and a fraction, NaN or a string writes an attribute browsers ignore. The build fails with a message naming the option — checked on the effective value, so a runtimeConfig.lukk.sessionMaxAge of your own counts — and a runtime override (NUXT_LUKK_SESSION_MAX_AGE) that isn't usable makes every session write throw rather than seal anything weaker.
bodyLimit
lukk: { bodyLimit: 1048576 } // bytes; default 1 MiBlukk-nuxt 0.12.0. The largest request body the BFF auth proxy (/api/_lukk/**) reads. Its routes are reachable unauthenticated and the body is buffered before anything else, so this bounds what one request can make the server hold. A declared Content-Length over the limit answers 413 before anything is read; a chunked body, which declares no length, answers 411; and the bytes are measured again once read. A passkey attestation is a few KiB, so the default is generous for anything lukk accepts. The app-API proxy streams and is unaffected.
clientIpHeader
The problem it solves. In BFF mode every upstream call is a fresh connection from your Nitro server, so the address your API sees is the proxy, not the visitor. Anything keying on $request->ip() therefore treats your entire user base as one identity — a throttle:5,1 on a public form becomes 5 requests per minute globally, and one user can lock out everyone else. lukk's own auth throttles (login, forgot-password, two-factor-challenge, refresh at 30/60s, and the logout refresh-token lookup that borrows refresh's limits) collapse the same way. The logout bucket is the one to watch after a "log out everywhere": the BFF's own background revokes of replaced sessions count against it like any visitor's retried logout. The claim route is the exception — it keys on the authenticated user, so it is unaffected.
Blanking the browser-settable forwarding headers is the right default — otherwise any client could claim any IP and defeat your rate limiting. This option is how you say "this hop is trusted" when it genuinely is:
// nuxt.config
lukk: { clientIpHeader: 'cf-connecting-ip' }Both proxies and the server-side token refresh then forward that address upstream as X-Forwarded-For. Unset, nothing changes.
It must be a header your edge sets
Name a header your edge overwrites — cf-connecting-ip behind Cloudflare, or x-real-ip if your own nginx sets it. A header your edge merely appends to (the usual X-Forwarded-For chain) leaves the leftmost entry client-controlled, and trusting it would let a visitor forge their address — worse than the shared bucket you started with. The module warns at build if you name one, and a list-valued header is rejected at runtime.
WARNING
Your origin must not be reachable except through that edge. There is no socket-peer check here (unlike Laravel's TrustProxies), so if someone can reach Nitro directly — a leaked origin IP, no firewall on your CDN's ranges — they can simply set the header themselves. Lock the origin down before enabling this.
Configure Laravel to trust only this header — and only your BFF
$request->ip() ignores X-Forwarded-For until TrustProxies trusts the hop. Trust the BFF's egress address, not "whoever connects":
// bootstrap/app.php
use Illuminate\Http\Request;
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: '10.0.0.5', headers: Request::HEADER_X_FORWARDED_FOR);
})CAUTION
at: '*' does not mean "trust every address" — it means trust whoever connected. If your Laravel origin is reachable from anywhere (the default on most hosts), an attacker connecting directly is the trusted peer, so their own X-Forwarded-For is believed. They then mint a fresh throttle bucket per request — defeating every per-IP limit, including the per-IP cap that is the only defence against password spraying — or pick a victim's address and burn their login budget. Use '*' only when the origin is network-isolated to the BFF.
CAUTION
Pass headers: — Laravel's default mask is the wrong one here. It also trusts X-Forwarded-Host, which the app-API proxy strips for safety. Up to lukk-nuxt 0.11 it was forwarded empty rather than removed, and trusting a blank host makes $request->getHost() return empty, so url(), route() and signed URLs on routes behind that proxy rendered as http:///…. From 0.12.0 it is removed outright, but scoping to HEADER_X_FORWARDED_FOR is still right: it is the only header you need from this hop, and it stops a spoofed host reaching your app through any other.
If a CDN or load balancer sits between your BFF and Laravel, list both hops — otherwise $request->ip() resolves to the BFF again and the option silently does nothing.
Restore your rate limits afterwards
If you previously raised lukk's limits to stop a BFF deployment throttling itself, lower them back to the per-user defaults once this is on. Inflated limits that were safe against one shared bucket become a per-attacker-IP budget — 5000 login attempts/minute/IP is no throttle at all.
Once buckets really are per-visitor, lukk keys IPv6 callers on their /64 rather than the full address (a subscriber typically holds a whole /64, so keying on the address would let one visitor mint unlimited buckets). Tune it with rate_limits.ipv6_prefix — raise it toward 128 if your users share a /64, as an office or campus LAN does. Note that per-IP limits alone no longer cap password spraying or signup floods across rotating addresses; keep the per-account backstops (account_max_attempts, the per-user 2FA limit) in place.
NOTE
On non-Node Nitro presets (Cloudflare, Deno, Bun, edge runtimes) there is no socket address to fall back to, so clientIpHeader is the only way your upstream ever learns the caller.
Enabling this means your auth server receives — and likely logs — visitor IP addresses, which are personal data under GDPR. lukk itself neither stores nor logs them (they become a short-lived cache key with a decay_seconds TTL); your own application logging is where any retention obligation lands.
confirmationHeader
confirmationHeader: 'X-Lukk-Confirmation'The HTTP header that carries a step-up confirmation token. Change it only if you've changed confirm.header on the lukk side — the two must match. In BFF mode both proxies inject it from the sealed session; in direct mode useLukkFetch() attaches the held token to requests on the API's own origin, never another (lukk-nuxt 0.12.0).
It must be a valid header name that neither the proxies nor the transport already own: not Authorization, Accept, Content-Type, Cookie or Via, not a forwarding header (X-Forwarded-*, X-Real-IP, Forwarded, …), not a hop-by-hop or Fetch-forbidden one (Connection, Content-Length, Origin, …), and not a Proxy- or Sec- name. Such a value fails the build — the one that ends up in effect, so a confirmationHeader set through runtimeConfig.public.lukk in nuxt.config is checked as well as the module option (lukk-nuxt 0.12.0); a runtime override (NUXT_PUBLIC_LUKK_CONFIRMATION_HEADER) that is one falls back to X-Lukk-Confirmation.
storage
storage: 'cookie' // the only accepted valueReserved. BFF tokens always live in the stateless sealed session cookie — no server-side store, no Redis, serverless-friendly. The option was never read; from lukk-nuxt 0.12.0 any value but 'cookie' fails the build, so remove it. For the record of ended sessions shared across instances, use session.sharedStore.
Overriding with environment variables
Because the options become Nuxt runtime config, they can be overridden at runtime with NUXT_-prefixed environment variables — handy for per-environment deploys:
| Variable | Overrides |
|---|---|
NUXT_LUKK_SESSION_PASSWORD | session.password (server-only) |
NUXT_LUKK_BASE_URL | the server-side baseURL (BFF) |
NUXT_PUBLIC_LUKK_BASE_URL | the public baseURL (direct) |
The same rule covers the other keys the module writes: runtimeConfig.lukk.* (server-only — NUXT_LUKK_API_TARGET, NUXT_LUKK_COOKIE_SECURE, NUXT_LUKK_SESSION_MAX_AGE, NUXT_LUKK_SHARED_STORE, NUXT_LUKK_CLIENT_IP_HEADER, NUXT_LUKK_BODY_LIMIT, …) and runtimeConfig.public.lukk.* (NUXT_PUBLIC_LUKK_USER_ENDPOINT, NUXT_PUBLIC_LUKK_CONFIRMATION_HEADER, …). Build-time checks can't see a runtime override, so a bad one surfaces at request time instead.
Next: Authentication.