Skip to content

Security Model ​

This is the security reference for reviewers and the security-minded: the invariants lukk holds, how the client keeps tokens out of the browser, and a checklist you can audit against. For the code layering behind these guarantees see Architecture; for the token internals, Tokens & Rotation.

The signing invariants ​

  • Algorithm pinning. The verifier pins the algorithm from config and stamps it onto every key; it never reads the alg from the token header. So an attacker cannot present an HS256 token signed with the public key as the HMAC secret (the classic RS256→HS256 confusion), and alg=none is rejected outright. Alg mismatches are rejected too.
  • Delegated crypto. The JWS layer is delegated entirely to the audited firebase/php-jwt. lukk never hand-rolls JWS, TOTP, or WebAuthn — the only sanctioned extra libraries are the 2FA and passkey ones, and they're loaded only when the feature is enabled.
  • Claim validation on every request. iss/aud/exp (required) plus nbf/iat (when present) are validated — each time claim a timestamp between the epoch and the end of year 9999 (RFC 7519 §2, lukk 0.7.0), so an absurd exp from a co-issuer is a rejected token, not a 500 — and the typ=at+jwt header is asserted — so a 2FA/step-up challenge token (same key, iss, aud) can't be replayed as a bearer. A typ that isn't a string, and an aud that is a JSON object rather than a string or an array of them (RFC 7519 §4.1.3), are refused rather than a 500 or a match on one of the object's values (lukk 0.7.0).
  • Secret floor. The HS256 secret is ≥ 256-bit random (php artisan lukk:secret); firebase/php-jwt v7 hard-enforces the minimum, so a too-short secret fails loudly instead of weakly signing.

Rotation, reuse & revocation ​

  • Opaque, hashed refresh tokens. Refresh tokens are opaque 256-bit random strings, stored only as sha256 at rest, never logged, never JS-readable, never serialized into any client bundle or hydration payload.
  • Secrets stay out of stack traces. Wherever lukk takes a secret as an argument — a refresh, bearer, challenge or confirmation token, a password, a TOTP or recovery code, the TOTP secret — it is #[\SensitiveParameter], on the TokenIssuer, TokenVerifier, TwoFactorProvider and TwoFactorAuthenticatable contracts too, so a logged exception or an error tracker doesn't carry it (lukk 0.7.0).
  • Rotation + reuse detection. Every refresh rotates the token. A post-grace replay of a consumed token revokes the entire family and denylists it by fid, killing every live access token for that session within one access_ttl, and dispatches RefreshTokenReused for alerting. Presenting an already-revoked token (a client retrying across a logout) answers 401 and dispatches no event — it is not evidence of theft — and from lukk 0.7.0 writes nothing either: its family's revoke has already run, and re-running it on every presentation made one dead token, replayed at an unauthenticated route, an unmetered write amplifier.
  • Revoke-then-throw runs outside the transaction. The family revoke happens after the rotate transaction commits — revoking inside it then throwing would roll back the revoke while the denylist cache write persisted, an inconsistency hole.
  • Grace window prevents false logout. The grace_seconds window serves concurrent legitimate refreshes (multiple tabs, SSR + hydration) a fresh sibling under the same family rather than treating them as theft. See Tokens & Rotation → The grace window for the accepted residual trade-off.
  • Instant, cheap revocation. The denylist lives in the cache (keyed by jti/fid), killing access within one request; global logout (DELETE /auth/sessions) works. Each entry self-evicts when its token would have expired anyway.

Login & responses ​

  • Constant-time login. Every failing path costs the same work and the same time, so a wrong identifier is indistinguishable from a wrong password (no user enumeration). An unknown identifier and an account with no password (passkey-only, SSO, invited) each cost one key derivation, exactly like a wrong password; a failure against a stored hash weaker than the configured cost (or an unreadable one) derives once more at the configured cost, and a successful sign-in rehashes the weak hash (honouring hashing.rehash_on_login) with a conditional write that replaces only the hash it checked, so it can't revert a password change committed meanwhile (only under Laravel's stock EloquentUserProvider using the application's own hasher — a subclass, or a provider built with a hasher of its own, may check passwords its own way, a pepper for instance, which Hash::make would silently drop; any other provider's hash is left alone). On top of that the whole credential check runs in a Timebox: a failure takes at least login_timebox_micros (500 ms by default), and only a success returns early, since its status code already says it succeeded (lukk 0.7.0). The box holds everything that differs between a real account and an unknown one — the account lookup, the lockout reads and writes (a real account has two counters, an unknown identifier one) and the key derivation. Login is throttled.

  • Forgot-password is padded too. A registered address writes a token row and sends (or queues) the notification; an unknown one does neither. The whole request is boxed to password_reset.timebox_micros (one second by default). Queue the notification so the real path stays inside the box.

  • A password change or reset shuts out a sign-in that was already in flight. A change or reset writes the new hash, then revokes the account's sessions; a sign-in starts its session, then re-reads the password, and takes the session back if it changed since the check. Each side commits before it reads, so one sees the other — a sign-in that checked the old password does not keep a session past a reset meant to recover the account (lukk 0.7.0). A two-factor challenge carries a fingerprint of the password it stands in for and is refused once that password changes — before the code is checked, so the refusal burns no recovery code and starts no session. It holds while:

    • the re-read is authoritative — lukk reads an Eloquent provider's user from the primary connection; a custom UserProvider must not answer retrieveById from a read replica or a cache;
    • no database transaction wraps the login, change-password or reset requests — a transaction-per-request middleware on those routes reopens the race;
    • APP_KEY rotations use APP_PREVIOUS_KEYS, or two-factor challenges in flight at the moment of rotation are refused (the user signs in again); deployments redeeming each other's challenges must share APP_KEY as well as the signing secret.

    With Lukk::authenticateUsing, lukk did not read the password itself, so it compares against the stored password read right after your callback returns.

  • No login CSRF in cookie mode. SameSite=Strict stops the refresh cookie being sent cross-site, not being set (RFC 6265bis §5.7), so a cross-site form could plant an attacker's session. In cookie mode every route that sets the cookie — login, register, refresh, two-factor-challenge, passkeys/login — answers 415 unless the request is JSON or Sec-Fetch-Site: same-origin/none, the rule logout already applied (lukk 0.7.0).

  • Non-cacheable token responses. Token responses carry Cache-Control: no-store, private so a shared cache/CDN never stores them.

  • Fail-safe error codes. Invalid, expired, revoked, reused and unclaimed refresh tokens all return 401, not 500, without leaking which of those it was. Expired or not-yet-valid access tokens — and tokens whose sub user was deleted — are rejected at the guard.

  • Logout ends the session with either credential the client holds. From lukk 0.7.0 POST /auth/logout is no longer behind auth:{guard}: a valid access token or the client's refresh token ends the session, so the most ordinary client state there is — an idle tab whose 15-minute access token has lapsed — can still log out instead of getting a 401 and leaving a refresh cookie valid for its whole TTL (RFC 7009 §2.1–2.2, ASVS 5.0 V7.4.1). An expired access token alone ends nothing, whatever its signature: honouring one would make every token that ever reached a log or a crash report a durable capability to end that session. The refresh cookie is honoured — and cleared — only on a request shape a cross-site form cannot produce: Content-Type: application/json (not CORS-safelisted, so a cross-origin page must pass a preflight your CORS policy governs, and matched on the MIME essence so text/plain; x=/json doesn't slip through), or Sec-Fetch-Site: same-origin/none. Not same-site — that is the sibling-subdomain attacker, whom SameSite=Strict does not stop. A throttled refresh-token lookup answers 429 with Retry-After and leaves the cookie in place, because the session is still live and the client must retry.

Password policy ​

lukk validates every new password (register, reset, change) as required, confirmed, at most 255 characters, free of NUL bytes, and meeting Password::defaults(). It imposes no composition rules of its own — that is your application's call, made once in Password::defaults():

php
// AppServiceProvider::boot()
use Illuminate\Validation\Rules\Password;

Password::defaults(fn () => Password::min(15)->uncompromised());
  • Length. NIST SP 800-63B-4 §3.1.1.2 asks for at least 15 characters when the password is the only factor, and allows 8 when it is used with a second one. With two-factor optional, most accounts are single-factor — set 15.
  • Blocklist. The same section requires checking new passwords against known-compromised values. uncompromised() does that against Have I Been Pwned's k-anonymity API (only a hash prefix leaves your server).
  • No truncation. bcrypt reads only the first 72 bytes, so a longer password would be stored truncated and any string sharing that prefix would sign in — §3.1.1.2 requires verifying the whole password. From lukk 0.7.0, under bcrypt a new password over 72 bytes is refused with a 422 (bytes, not characters: 36 two-byte characters fill it). Prefer argon2id (HASH_DRIVER=argon2id), which has no such limit, if long passphrases matter to you. A Lukk::registerValidation() hook — or a rebound RegisterRequest subclass overriding rules() — replaces the default registration rules, so lukk adds this check — with "must be a string" and a refusal of NUL bytes — after yours; when a Lukk::registerUsing() hook does the create from such a rule set, only the string and NUL checks, since the hook may not hash with bcrypt at all. Registration requires a string password under every rule set, rather than trusting Password::defaults() to: a rule object of your own registered there let a JSON number through to be cast and stored. Sign-in, password step-up and change-password's current_password refuse a password over 72 bytes against a bcrypt-stored hash too ($2a$, $2b$, $2x$ or $2y$), which they could verify only in part. Every lukk release before 0.7.0 stored such a password — truncated — as does an import or a hasher with hashing.bcrypt.limit off, so its owner is typing the right password: they get a distinct 422, "This password is longer than 72 bytes, which can no longer be checked in full. Reset your password to continue.", rather than "do not match", and must reset. That answer reveals nothing new: only a caller whose first 72 bytes already match reaches it, and earlier releases signed that caller straight in. At sign-in it still counts as a failed attempt. You can't find these users in the database (the hash doesn't record the length), so consider telling them before you upgrade. A Lukk::authenticateUsing() callback decides for itself.

Transport hardening (client) ​

Where the tokens physically live is a transport-mode choice, and each mode has its own containment.

BFF mode — nothing in the browser ​

  • No token in localStorage, ever. BFF keeps every session token — access, refresh, and the step-up confirmation token — server-side in a sealed, encrypted cookie, so XSS can't exfiltrate one. Two things qualify "the browser holds only the opaque session cookie". There are also two short-lived cookies that drive a logout (__Host-lukk-logout, one minute; __Host-lukk-signed-out, ten seconds). Neither is a credential: their presence is what the server acts on. The first carries one value — the moment the logout was asked for, which the page that finishes the note needs and cannot otherwise know; without it that page used its own load time, and a sign-in already on the wire could never count as having happened since. Both are readable by JavaScript because the page writes and reads them itself. And the two-factor login challenge token is held client-side in both modes: it is issued before any session exists, so there is nothing server-side to seal it into and the browser has to present it back. It is short-lived, single-use, account-throttled, and never written into the SSR payload.
  • Credential stripping. The proxy replaces a token- or confirmation-bearing response body with { ok: true } (plus expires_in where the client needs it) before it reaches the browser, and separately strips those three keys from any body passing through — into arrays and nested objects at any depth (lukk-nuxt 0.12.0; it used to stop four levels down), and strips every upstream Set-Cookie (except names you opt into with api.forwardSetCookie, which defaults to none and can never carry a lukk session name) (re-emitting only lukk's sealed session cookie).
  • CSRF containment. Moving tokens server-side trades XSS-exfiltration risk for CSRF risk, closed two ways: the session cookie is __Host-lukk-session (SameSite=Strict; Secure; HttpOnly; Path=/, no Domain — the __Host- prefix the browser enforces), and the proxy rejects a state-changing request whose Origin doesn't match the app's own host (403). A request carrying no Origin is not refused on that test alone — it has already had to pass the Sec-Fetch-Site check, which refuses cross-site and same-site, and the session cookie is SameSite=Strict. A GET from another site or a same-site sibling is refused too (403) unless it is a top-level navigation (lukk-nuxt 0.12.0): a sibling's <img src="/api/_lukk/account/export"> would otherwise ride the session and spend the user's step-up throttle. "Top-level" means Sec-Fetch-Dest: document — a framed navigation (<iframe>, <frame>, <embed>, <object>) is the embedding site's doing, not a link the visitor followed (Fetch Metadata §2.1). A link in an email still works. Both proxies apply this, the app-API proxy too: it injects the bearer from the sealed session, so a sibling's <img src="/api/export"> was otherwise answered as the visitor. CSRF is enforced by origin, not a token, so Laravel's token-based CSRF (419) doesn't apply.
  • SSRF containment. The proxied subpath is contained to lukk's base URL (no traversal); the app-API proxy forwards to a fixed target. Both strip the inbound cookie/authorization and the browser-spoofable forwarding headers on a fixed deny-list (x-forwarded-host, -proto, -port, the path/scheme family x-forwarded-prefix/-scheme/-ssl/-uri/x-original-url/x-rewrite-url, and the rest of SPOOFABLE_FORWARDING in runtime/shared.ts; x-forwarded-for is re-stamped from the socket rather than dropped) (stamping a trusted client IP so Laravel's per-IP throttling/logging can't be poisoned) and mark responses non-cacheable.
  • The app-API proxy never reaches lukk's own routes (lukk-nuxt 0.12.0). A request whose upstream URL falls under lukk's base path answers 404, decided on the resolved URL and regardless of host, so /api/auth/login or /api/auth/confirm-password can't stream a token pair or a step-up token to the browser when api.target and baseURL are the same app. That includes a path through a PHP front controller, which Laravel routes like the bare path: every .php is read as the end of a script's name, as Symfony strips one — with no / needed after it, so /api/index.phpauth/login is /auth/login too — wherever it sits (/api/index.php/auth/login, a Laravel app under a sub-path at /app/index.php/auth/login), in the request path and in baseURL alike (https://host/index.php/auth), and also when only a decoding hop reveals it (/index.php%2Fauth/login) — while the path is judged as written too, so such a segment never hides an encoded /, ?, # or ... A script in a subdirectory is read the way Symfony strips it, with everything before it (/api/public/index.php/auth/login); and since Symfony's last resort cuts a path by the length of the deploy's SCRIPT_NAME, which the proxy can't know, a path that names a .php anywhere and contains lukk's last base segment (auth) whole anywhere is refused — an over-refusal that catches paths like /api/oauth/callback.php too — and so is a path naming more than four .php in any decoding round, whatever it holds, which keeps the number of readings judged in full bounded and the check linear in the path. So is one that a further round of percent-decoding would turn into lukk's routes (/api/x/%252e%252e/auth/login, /api/%2561uth/login) — checked through four rounds, and refused if it still decodes to something new after that — so a CDN, rewrite rule or second proxy that decodes again can't hand lukk's token responses to the browser. A decoded ? or # is followed both as a cut and as path data (/api/x%3F/%252e%252e/auth/login), and a path in which decoding reveals a whole . or .. segment — encoded twice, behind an encoded %2F/%5C separator (a%2F..%2Fb), or split by a tab, LF or CR — is refused wherever it leads, since a chain of rewriting hops can walk it somewhere no single reading predicts (RFC 3986 §5.2.4). A path check can't see two things, so keep lukk off api.target's reach — on its own host, or under a prefix the app API isn't reachable through. It can't see lukk's extra guard mounts (lukk.guards.*.path) on the same host. And it can't see a front controller that no .php in the URL names: shared hosting whose .htaccess rewrites into public/index.php routes /public/auth/login as /auth/login, and without the deploy's SCRIPT_NAME no path check contains it.
  • Both proxies bound the path and judge it in linear time (lukk-nuxt 0.12.0). A path past 8 KiB as the upstream URL serialises it (query aside; nginx's and Apache's default request-line limit) answers 414 before the session is read or the path judged. Earlier, an unauthenticated request could spend seconds of CPU in the route checks: the front-controller readings were quadratic in the number of .php, and the trailing-slash trimming and the auth proxy's empty-segment check rescanned a long run of / from each slash in it. The auth proxy refuses (404) a path with an empty segment, an encoded / or \, or an encoded %, which a slash-merging or decoding hop in front of lukk could turn into a route, so it never decides policy on one URL and forwards another.
  • A page render never rotates the session. The rotated cookie of a request the render makes goes back to the render, not the browser, which would then replay the consumed token. So the session is renewed once, before the render starts — by SSR hydration, or with ssrHydrate: false by a renew-only step that loads no user and marks the render no-store — and that cookie leaves with the page. A BFF server plugin marks the page request (x-lukk-ssr, removed before a request reaches your API), so every request the render makes carries it — useLukkFetch, and Nuxt's own useFetch('/api/…'), useRequestFetch() and event.$fetch alike: the app-API proxy forwards a marked request without renewing, and the auth proxy passes a marked request's 401 through and answers a marked /refresh with 401 without rotating (a logout still renews, to revoke) (lukk-nuxt 0.12.0). A server route's own event.$fetch('/api/…') is not a render and is not marked; read the session there with getLukkAccessToken.
  • The app-API proxy keeps your origin's CORS policy its own (lukk-nuxt 0.12.0). It drops the upstream's Access-Control-* response headers and its hop-by-hop headers (RFC 9110 §7.6.1), and removes Origin upstream — so an API that echoes *.example.com with credentials can't let a sibling subdomain read authenticated responses carrying the bearer the proxy injected.
  • Removed means removed. The headers the app-API proxy strips (Origin, Cookie, the spoofable forwarding set, anything named in Connection) are dropped, not forwarded empty (lukk-nuxt 0.12.0) — an empty Origin is still one a CORS layer upstream sees as present and judges. Only the ones the proxy blanked itself are removed: a header the browser sent with an empty value is forwarded as sent, since an empty field value is a legitimate one (RFC 9110 §5.5).
  • A hung lukk can't stall a request — but a rotation is never abandoned. The auth proxy's own calls to lukk, and background revokes, give up after 15 seconds — body reads included — and are treated as an outage. The server-side refresh call deliberately has no such deadline: lukk commits the rotation on receipt, so an abort would lose the replacement token and the session's next refresh, past grace, would be a replay that revokes the whole family (RFC 9700 §4.14.2) — a false logout. It waits for lukk, bounded by the runtime's transport timeouts, and every request on the session shares that one call. The callers waiting on it are bounded: after 15 seconds /api/_lukk/refresh answers 503 with Retry-After: 5, keeping the session, and lukk-nuxt's client retries once after it. Every rotation that lands is journalled per session, as a link from the token it consumed to the pair it produced: a request still presenting a consumed token is handed the chain's newest pair (waiting on it if that pair's own token is being rotated) instead of replaying a spent token into a family revoke. A link is held ten minutes until its pair is delivered — until a response carrying it actually leaves, which for an app-API long-poll, a slow upload or a long render is well after the rotation landed — and 30 s from then, for the requests already out with the old cookie (so a hold lasts at most 10 min + 30 s); it is also cut to 30 s when the rotation of the pair it handed out lands, and after that only ever shortened, never past the next link's end (lukk-nuxt 0.12.0; the 30 s used to count from the rotation landing, so a slow response left the browser on the consumed token after its window had closed). Every caller re-checks the journal right before its cookie leaves — the auth proxy as it seals, the app-API proxy when the upstream's headers arrive (also on the 502 for an unreachable upstream, but not for a browser that went away first), SSR hydration once the page has rendered — and seals the newest pair; or, when the session has moved past its pair and no link leads on any more (a response slower than the links' window: 30 s from delivery, up to 10 min 30 s for one never delivered), withholds its session cookie, the BFF remembering each session's newest refresh token to tell — ten minutes past the latest rotation, hand-out or response, and for as long as any response that may seal a pair for the session is still out — so a straggler never seals a pair the session rotated meanwhile over the browser's newer cookie. A step-up confirmation and /api/_lukk/refresh seal the newest pair the same way; a step-up whose pair the session has moved past with no link leading on is not recorded — the auth proxy answers 409 "Your session was renewed meanwhile. Please confirm again." and the user is asked to confirm again (its calls give up after 15 s, so in practice only a journal that overflowed meanwhile gets there). And the app-API proxy and SSR hydration re-check for an ended session after a re-seal, dropping the session cookie and revoking the pair they sealed if a logout or sign-in landed meanwhile. A logout, and the revoke of a session a sign-in replaced, present the journal's current pair rather than the cookie's spent one, so lukk ends the session through the bearer instead of reading a replay and firing RefreshTokenReused. The journal is dropped when the session presents a token outside it, when lukk refuses one of its tokens, and when the session ends. So it can answer a replay well after lukk rotated — up to a slow response's duration + 30 s for a pair delivered late, 10 min + 30 s for one never delivered — past lukk's own grace window, and those replays never reach lukk's reuse detection — two bounded deviations, traded against a false logout and listed under Known limitations. The journal is per process; see A slow rotation for what it covers and the accepted limits. One that never settles stops being shared after five minutes.
  • Route rules on the URL lukk receives. The auth proxy decides "never proxy refresh", "no retry on a sign-in" and "clear on logout" on the normalized route, so /./refresh or /refresh/ can't slip past them.
  • Response hygiene. Auth-proxy responses are labelled application/json; charset=utf-8 or text/plain; charset=utf-8 — never an upstream text/html on your origin — and carry X-Content-Type-Options: nosniff. App-API responses keep the upstream's type but carry nosniff too, and any that is neither JSON nor a PDF gets Content-Security-Policy: sandbox added to the upstream's own policy, so an HTML or SVG document from your API renders with an opaque origin and no script.
  • Refresh is POST only. /api/_lukk/refresh answers anything else with 405 (Allow: POST), so no prefetch or <img> can spend a rotation (RFC 9110 §9.2.1).
  • Bounded input, expiring seals. The auth proxy refuses a body over bodyLimit (413, or 411 for a chunked body), and every seal carries session.maxAge, so a copied session cookie stops unsealing. That lifetime must be a positive whole number of seconds: iron reads a ttl of 0 as "never expires", so a build with anything else fails, and a runtime override that isn't one makes every session write throw rather than seal something weaker.

WARNING

Keep the sealed session under ~4 KB. The __Host-lukk-session cookie holds the access JWT plus the refresh and confirmation tokens and a session id, iron-sealed (~1.34× inflation on top of a fixed envelope). Per RFC 6265bis §5.6 a browser silently drops any cookie whose name+value exceeds 4096 octets — so a bloated access token can make login appear to succeed while the cookie never persists and every following request is anonymous. This only bites with large custom claims via Lukk::tokenClaimsUsing; keep claims lean and put bulky authorization data behind an API lookup keyed by sub. lukk-nuxt emits a console.warn as the sealed session nears the limit.

  • The access token lives in client memory (never localStorage) and is never written during SSR, so it never lands in the hydration payload. From lukk-nuxt 0.12.0 it, the step-up token and a pending 2FA challenge are held on the Nuxt app rather than in useState: useState is the payload, and Nuxt's chunk-reload persists it to sessionStorage (nuxt:reload:state), where experimental.restoreState re-applies it. lukk's own session state (lukk:user, lukk:ready, lukk:confirmed, …) is taken out of that snapshot before Nuxt restores it, so a stale user or step-up from before the reload can't overwrite the session the new page resolved; useLukkForm drafts stay.
  • The refresh token rides lukk's __Host-refresh cookie (HttpOnly; Secure; SameSite=Strict), sent automatically only on refresh. Its lifetime is what is left of the session's (lukk 0.7.0): a family's expiry is absolute, fixed at sign-in, and each rotation used to re-set the cookie for the full refresh_ttl, so late in a session the browser held it for weeks past the point lukk would accept it.
  • Web storage holds no credential, but it isn't empty. lukk-js writes lukk:logging-out:<app.baseURL>[#<session.name>] (sessionStorage, one minute) and lukk:signed-in-at:<app.baseURL>[#<session.name>] (localStorage) so the page load after a logout can finish it. Both hold bookkeeping only — a timestamp and, when known, the session's refresh-token family id, which is not a credential and cannot be exchanged for anything. Clearing localStorage on logout can make the next page load end a newer session; see Transport Modes → Direct.
  • Credentials are origin-scoped. The client attaches the bearer / confirmation header (and cookies) only to a same-origin-as-baseURL target, never to an absolute cross-origin URL, and uses credentials: 'same-origin'. useLukkFetch sends the step-up token to the API's own origin, as it does the bearer (lukk-nuxt 0.12.0), and a relative path with an explicitly empty baseURL — which goes to the page's origin — carries no credentials unless the page is the API's origin. During SSR, useLukkFetch forwards the visitor's cookies only to this app, never to an absolute API host, and never trusts the request's Host to decide what "this app" is.
  • A request made for one account is not replayed as another. A 401 is renewed for only where credentials went, and when the renewal comes back for a different account (another tab signed in; the refresh cookie is shared), the request rejects with SESSION_CHANGED instead of being retried as that account. A sign-in, or a renewal into another account, also drops any step-up the previous session earned (lukk-nuxt 0.12.0).

WARNING

The access token is reachable by JavaScript in direct mode. Any script on the page — including injected script under XSS — can read the in-memory token and call the API as the user until it expires. Minimise your XSS surface and set a strict Content-Security-Policy. If you need the browser to hold no token at all, use BFF mode.

SSR hydration ​

In BFF mode the server can hydrate the authenticated user during server rendering. The invariants hold: no token in the payload (only your app user resource is serialized; the access/refresh token never leaves the server), a page embedding a per-user identity is marked Cache-Control: no-store so a shared cache can't cross-serve renders, and an anonymous/tampered/expired-seal request fails safe as logged-out with no minted cookie and no 500. See Transport Modes → SSR hydration.

NOTE

Throttling under BFF. Every user's auth traffic egresses from the BFF server's IP, so lukk's per-IP refresh/login throttles collapse onto one address — including the logout refresh-token lookup, which the proxy's own background revokes of replaced sessions spend as well. (The claim route keys on the authenticated user and is unaffected.) Forward the real client with clientIpHeader (and restore any limits you had raised to compensate — inflated limits become a per-attacker budget once forwarding works). Keep grace_seconds > 0: the proxy single-flights refresh, but a zero grace window turns any concurrent refresh into a full-family revocation.

Standards mapping ​

RequirementStandard
Pin the algorithm on decode; reject alg=none and mismatchesRFC 8725
Validate iss/aud/exp (required) + nbf/iat when present; carry jtiRFC 7519, 8725
typ=at+jwt header (explicit typing, so a challenge token can't pass as a bearer)RFC 8725 §3.11, RFC 9068 §2.1
Access-token claims follow RFC 9068's shape (iss, aud, sub, exp, iat, jti, scope) without claiming conformance: there is no OAuth client, so no client_idRFC 9068 (partial, by design)
Short-lived access tokens (15 min by default, access_ttl)RFC 9700
Refresh-token rotationRFC 9700 §4.14.2
Reuse detection → family revokeRFC 9700 §4.14
Concurrency without false logout (grace window) — a deliberate deviationfosite / Okta reuse interval
Refresh opaque + sha256 at rest; never loggedRFC 9700 / OWASP
Instant revocation (denylist by fid/jti)OWASP Session Management
Login throttled + constant-time (no user enumeration)OWASP ASVS
amr uses only registered valuesRFC 8176
Passkey UV relied on only for a credential already uvInitializedWebAuthn L3 §4
Step-up for binding authenticators at the account's highest available AAL; user notified out of band on bindingNIST SP 800-63B-4 §4.1.2.1
Consecutive failed attempts on one account capped (opt-in)NIST SP 800-63B-4 §3.2.2; ASVS 5.0 6.1.1 / 6.3.1 (ASVS 4.0.3 V2.2.1)
Tokens kept out of the browser; sealed __Host- cookieOAuth 2.0 for Browser-Based Apps
Token responses non-cacheable (Cache-Control: no-store, private)RFC 6749 §5.1
Reuse/family-revoke emits a security eventa lukk addition, for OWASP's security-logging guidance — RFC 9700 §4.14.2 asks only for the revoke

Known limitations ​

These behaviours are accepted trade-offs rather than gaps. All are bounded, and all are here so you can weigh them yourself rather than discover them.

The rotation grace window departs from RFC 9700 §4.14.2. A refresh token replayed within grace_seconds of a legitimate refresh yields a sibling instead of a family revoke, so a thief winning that race gets a parallel chain that reuse detection won't catch. The alternative — strict invalidate-on-replay — logs out any client with two tabs open. See the full reasoning; RefreshFamilyForked makes the fork visible.

The lukk-nuxt BFF answers some replays itself. Its rotation journal hands a request still presenting a consumed refresh token the session's newest pair, so that replay never reaches lukk: no RefreshTokenReused, no RefreshFamilyForked. RFC 9700 §4.14.2 reads such a replay as a breach signal; the BFF reads it as the same browser catching up after a slow rotation or with a burst of requests on the old cookie. It is bounded to the journal's windows — ten minutes until a rotation's pair is delivered (at most 10 min + 30 s), 30 s from the moment a response carrying the pair leaves — and to one server process; outside them the replay reaches lukk as before. Those 30 s count from delivery, not from when lukk rotated, so on a slow refresh or a slow app-API response (a long-poll, an upload, a long render) the journal answers a replay up to that request's duration + 30 s after the rotation — past lukk's own grace_seconds. That is deliberate: counted from lukk's rotation, a slow request's link expired before its cookie reached the browser, and the requests still carrying the old cookie replayed it into a family revoke — a false logout.

An erased identifier survives briefly as a rate-limiter cache key. Erasure sweeps every durable artifact — including the lockout counters, which are keyed by user id and by a digest of the folded identifier — but the decaying login throttle buckets live in the cache. From lukk 0.7.0 their keys hold no identifier in the clear: a SHA-256 of the folded identifier (never of the account), under acct|<guard>|… for the IP-independent bucket and paired with the caller's address for the per-address one. Those keys expire on their own within rate_limits.login.decay_seconds, so the residue is self-clearing and short. lukk does not sweep them: the per-address bucket cannot be reconstructed after the fact (the addresses aren't known), so a partial sweep would clear the tidy half and leave the other, while implying completeness. If your retention posture can't accept a bounded window here, shorten decay_seconds or point denylist_store at a store you flush on erasure.

Look-alike identifiers share a rate-limit bucket. The decaying login throttle keys on the identifier normalized (trimmed, lowercased, transliterated), which is many-to-one across distinct accounts: аdmin@example.com with a Cyrillic а folds onto admin@example.com. So a victim's failures can briefly throttle a look-alike account. Up to lukk 0.6 a success cleared both buckets, so the look-alike's owner could sign in between bursts to reset the victim's count; from 0.7.0 a success clears only the caller's per-address bucket, and the IP-independent one decays. Keying on the resolved account instead was tried and rejected: a bucket only real accounts have is visible through its timer, because a spelling the database does not resolve counts in the fold alone, so Retry-After, and after one bucket expires the status itself, would answer whether an account exists.

The opt-in account lockout's two counters can expire apart. Every login failure counts against the folded identifier, and against the account too when the identifier resolves to one; either locks. (Earlier releases counted the account or the fold, not both, so a spelling the database does not resolve — another case on a binary collation, a Cyrillic look-alike — locked a non-account's canonical spelling but not a real account's, and 423 versus 422 answered whether an account exists.) Counting both closes that with the default release_after of 0. With release_after above zero the two counters can time out at different moments, which leaves a slow timing residue; and a victim's failures can lock a look-alike account's fold, as before 0.6. Only the account's own success, reset or password change releases its id: counter, so a look-alike account cannot release the victim. Applies only with features.lockout on.

The logout lookup budget is itself a side channel, and anyone sharing your address can spend it. POST /auth/logout has no authenticated identity to meter by, so the refresh-token lookup is bucketed by caller address, and only unproductive lookups — a miss, or a family already revoked — are counted. Two consequences, both accepted rather than engineered away:

It leaks one bit per budget. Spend max_attempts − 1 misses, present a candidate token, then one known miss: a 204 means the candidate resolved to a live family, a 429 means it did not. That is a whole bucket (30 requests by default) per bit about a 256-bit random token, which is why the cost is judged acceptable; the alternative is either not metering the lookup at all, or answering something other than the truth about the throttle. A burst racing past the pre-check can also overshoot the limit by its own concurrency before the counts land.

It is exhaustible by a neighbour. Junk misses from one client behind a NAT, a carrier, or a proxy that doesn't forward the real address spend the bucket for everyone behind it, and their cookie-only logouts answer 429 until it decays. Nobody is logged out wrongly and everyone can retry — a 429 leaves the refresh cookie in place — but the refusal is real. Behind a BFF, set clientIpHeader; the same forwarding that fixes the login and refresh buckets fixes this one.

An unfinished logout can expire before anything finishes it. When logout() can't complete — the page navigated away, lukk is unreachable — lukk-js leaves a note (a one-minute cookie in BFF mode, a one-minute sessionStorage entry in direct mode) and the next page load ends the session from it. In BFF mode that page renews the note's minute (carrying forward the moment the logout was asked for, never re-stamping it). Direct mode does not renew: its note's minute runs from the moment logout() was called, whether or not it names a session. The visitor reads as signed out either way, locally and on every render. But if lukk stays unreachable, the browser's own retry never lands, and no page load happens inside that minute, the note expires and the session stays live on lukk until its refresh token does. The alternative — a note with no expiry — means a stale note ending a session signed into days later, which is a worse failure. If the window matters for your threat model, shorten refresh_ttl, and use DELETE /auth/sessions for the "sign me out everywhere, now" case, which is a single authenticated request with no note involved.

A logout that stands down still resolves successfully. A logout() whose work outlives the page leaves a note, and the page load that finds it stands down instead of sending, if there is positive evidence of a sign-in on this app since that logout was asked for — the tab signed in again, or another tab did. Standing down is the right answer (the session the note was for is gone, and sending would end the newer one), but the original logout() call had already resolved without throwing, so a .then() that shows "signed out" was right about the old session and says nothing about the new one. Read the reactive state (loggedIn, user), not the promise, if what you want to show is whether this browser is signed in now.

A sign-in that arrives with no session cookie can be read as signed out, once. In BFF mode a sign-in records that it replaced the session it arrived with — which is how an in-flight request that is still finishing an older logout knows not to answer "signed out" to a browser that has since signed in again. A sign-in whose request carries no session cookie (the logout that preceded it had already cleared it) has nothing to record: the old session's key is not in that request, and the server has no other way to name it. A page load still in flight for the old session can therefore set the ten-second signed-out marker on a browser that now holds a new one, and that one page renders signed out. The session cookie itself is untouched, the visitor's other tabs correct it through the cross-tab channel, and the next load is normal. Fixing it would need a per-browser identifier that outlives the cookie, which is the thing the design deliberately does not keep.

No per-session listing or revoke. You can end the current session (POST /auth/logout), every session (DELETE /auth/sessions) and every other session (DELETE /auth/sessions/others) — but there is no "here are your five devices, sign out the third" surface. The data exists (RefreshTokenRepository::allForUser), and GET /auth/account/export returns it for the GDPR path, but nothing revokes one session by id. If you need that, build it on the repository.

Security checklist ​

  • [x] Decode always passes an explicit algorithm; alg=none and mismatches rejected.
  • [x] iss/aud/exp/nbf validated on every request; aud bound to the API.
  • [x] Access tokens short-lived (access_ttl, 15 min by default); header typ=at+jwt stamped and asserted — a 2FA/step-up challenge token (same key/iss/aud) is rejected as a bearer.
  • [x] Refresh tokens opaque, sha256 at rest, never logged, never JS-readable.
  • [x] Invalid/expired/revoked/reused/unclaimed refresh tokens return 401, not 500, without leaking the reason.
  • [x] Rotation on; post-grace replay revokes the whole family.
  • [x] Logout works without a valid access token: a live refresh token ends the session, an expired access token alone ends nothing, and the refresh cookie is honoured and cleared only on a request shape a cross-site form can't produce (Content-Type: application/json, or Sec-Fetch-Site: same-origin/none — never same-site). A throttled lookup answers 429 and leaves the cookie for the retry.
  • [x] Cookie mode: the routes that set the refresh cookie refuse (415) a request a cross-site form could produce.
  • [x] Grace window prevents false logout under concurrency.
  • [x] Denylist (fid/jti) kills access within one request; global logout (DELETE /auth/sessions) works. A revoke covers the longest access-token lifetime still alive, so lowering access_ttl during an incident doesn't bring revoked tokens back; bulk revokes act on the families they read, never on sessions started meanwhile.
  • [x] A presented token that is already revoked does no work; DELETE /auth/sessions/others with a bearer that names no session (no fid to keep, or an empty one) answers 409 rather than a 204 that revoked nothing, naming the revoke-all route where the guard mounts it. (With no bearer at all — Lukk::actingAs() in tests — it stays a 204 no-op.)
  • [x] leeway clamped to 0–300 s for every token lukk decodes (RFC 7519 §4.1.4), and the same clamped value used for every denylist TTL and the unclaimed-session window, so a negative leeway can't shorten a revocation.
  • [x] amr and auth_time survive refresh and are reserved — a claims hook can neither forge nor erase them.
  • [x] Login throttled; password check constant-time and padded to a wall-clock budget on failure; unknown user indistinguishable from wrong password; forgot-password padded the same way.
  • [x] A success clears only the per-address login bucket, so a look-alike account cannot reset another's IP-independent count, and both buckets key on the fold so 429 never answers whether an account exists.
  • [x] A sign-in checked against a password that is changed or reset before its session exists keeps no session — on the password path and on the two-factor path, where the refusal comes before the code is checked and spends nothing.
  • [x] Step-up confirmation throttled per user and per IP, so a stolen access token can't brute-force the password behind the sudo gate.
  • [x] A password step-up — and a password change — meets the account's highest available level: on an account with an enforced second factor or a user-verifying passkey it needs a token whose amr carries mfa and whose auth_time is within confirm.mfa_max_age.
  • [ ] (Optional) Account lockout enabled if you must meet NIST SP 800-63B-4 §3.2.2 — the throttles bound a rate, not a run of consecutive failures. Off by default: a hard lockout is a denial-of-service primitive, so set release_after to bound the denial.
  • [x] HS256 secret ≥ 256-bit random (php artisan lukk:secret); v7 enforces the minimum.
  • [x] Token responses carry Cache-Control: no-store, private.
  • [x] Reuse/family-revoke dispatches Events\RefreshTokenReused.
  • [x] Expired/not-yet-valid tokens, and tokens whose sub user was deleted, rejected at the guard.
  • [x] BFF: browser holds no token; session cookie __Host-, SameSite=Strict, seal expires after session.maxAge; proxy Origin-checks state-changing requests; upstream Set-Cookie and X-Forwarded-* stripped; request body bounded, and the path too (414 past 8 KiB, judged in linear time); responses nosniff; both proxies refuse cross-site subresource GETs; refresh POST-only and never aborted mid-rotation; app-API proxy has a fixed SSRF-safe target, refuses lukk's own routes (also after re-decoding, and behind a .php front controller), never rotates for a page render's own requests, and sandboxes non-JSON, non-PDF responses; a rotation's straggler window starts when its cookie leaves.
  • [x] (2FA) Challenge single-use + short TTL; TOTP single-use within its window; account-throttled; recovery codes salted+hashed and single-use; secret encrypted; enroll→confirm before activation; step-up to manage; amr reflects otp (and mfa after a password); binding or removing a factor dispatches an event so the user can be told out of band.
  • [x] (Passkeys) Challenge server-generated, single-use, origin/RP-ID bound; ceremony_id must be a UUID; assertion checks UP/UV + signature + pinned algorithms; registration checked against the advertised algorithms; sign-count regression rejected, but a credential whose stored counter is 0 never flagged (once non-zero, an incoming 0 is a regression), and the counter written forward-only so concurrent assertions with one counter can't both pass; a credential deleted mid-ceremony refused, whatever its counter; a step-up assertion bound to the signed-in account's user handle; credential IDs globally unique; public key encrypted at rest; amr is pop+user, with mfa only for an assertion with UV on a credential already uvInitialized; a single-factor passkey still faces an enrolled TOTP; step-up must meet the account's highest available level; registering or removing one dispatches an event.

Next: Architecture