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.
- 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=noneis 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) plusnbf/iat(when present) are validated, and thetyp=at+jwtheader is asserted — so a 2FA/step-up challenge token (same key,iss,aud) can't be replayed as a bearer. - Secret floor. The HS256 secret is ≥ 256-bit random (
php artisan lukk:secret);firebase/php-jwtv7 hard-enforces the minimum, so a too-short secret fails loudly instead of weakly signing.
- Opaque, hashed refresh tokens. Refresh tokens are opaque 256-bit random strings, stored only as
sha256at rest, never logged, never JS-readable, never serialized into any client bundle or hydration payload. - Rotation + reuse detection. Every refresh rotates the token. A post-grace replay of a consumed (or revoked) token revokes the entire family and denylists it by
fid, killing every live access token for that session within oneaccess_ttl. It dispatchesRefreshTokenReusedfor alerting. - 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_secondswindow 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.
- Constant-time login. The password check is constant-time; the unknown-user path runs an equivalent
Hash::check, so a wrong email is indistinguishable from a wrong password (no user enumeration). Login is throttled. - Non-cacheable token responses. Token responses carry
Cache-Control: no-store, privateso a shared cache/CDN never stores them. - Fail-safe error codes. Invalid/expired/revoked/reused refresh tokens return
401, not 500, without leaking the reason. Expired or not-yet-valid tokens — and tokens whosesubuser was deleted — are rejected at the guard.
Where the tokens physically live is a transport-mode choice, and each mode has its own containment.
- No token in
localStorage, ever. BFF keeps every token — access, refresh, and the step-up confirmation token — server-side in a sealed, encrypted cookie. The browser holds only the opaque session cookie, so XSS can't exfiltrate a token. - Credential stripping. The proxy replaces any token- or confirmation-bearing response body with
{ ok: true }before it reaches the browser, and strips every upstreamSet-Cookie(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=/, noDomain— the__Host-prefix the browser enforces), and the proxy rejects any state-changing request whoseOrigindoesn't match your app (403). 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 any browser-spoofableX-Forwarded-*headers (stamping a trusted client IP so Laravel's per-IP throttling/logging can't be poisoned) and mark responses non-cacheable.
Warning
Keep the sealed session under ~4 KB. The __Host-lukk-session cookie holds the access JWT plus the refresh and confirmation tokens, 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. - The refresh token rides lukk's
__Host-refreshcookie (HttpOnly; Secure; SameSite=Strict), sent automatically only on refresh. - Credentials are origin-scoped. The client attaches the bearer / confirmation header (and cookies) only to a same-origin-as-
baseURLtarget, never to an absolute cross-origin URL, and usescredentials: 'same-origin'.
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.
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 — raise them for a BFF deployment and forward X-Forwarded-For. Keep grace_seconds > 0: the proxy single-flights refresh, but a zero grace window turns any concurrent refresh into a full-family revocation.
| Requirement | Standard |
|---|---|
Pin the algorithm on decode; reject alg=none and mismatches |
RFC 8725 |
Validate iss/aud/exp (required) + nbf/iat when present; carry jti |
RFC 7519, 8725 |
typ=at+jwt header |
RFC 9068 |
| Access TTL ≤ 15 min | RFC 9700 |
| Refresh-token rotation | OAuth 2.1 §6 |
| Reuse detection → family revoke | RFC 9700 §4.14 |
| Concurrency without false logout (grace window) | fosite / Okta reuse interval |
Refresh opaque + sha256 at rest; never logged |
RFC 9700 / OWASP |
Instant revocation (denylist by fid/jti) |
OWASP Session Management |
| Login throttled + constant-time (no user enumeration) | OWASP ASVS |
Tokens kept out of the browser; sealed __Host- cookie |
OAuth 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 event | RFC 9700 §4.14.2 |
- Decode always passes an explicit algorithm;
alg=noneand mismatches rejected. -
iss/aud/exp/nbfvalidated on every request;audbound to the API. - Access TTL ≤ 15 min; header
typ=at+jwtstamped and asserted — a 2FA/step-up challenge token (same key/iss/aud) is rejected as a bearer. - Refresh tokens opaque,
sha256at rest, never logged, never JS-readable. - Invalid/expired/revoked/reused refresh tokens return
401, not 500, without leaking the reason. - Rotation on; post-grace replay revokes the whole family.
- Grace window prevents false logout under concurrency.
- Denylist (
fid/jti) kills access within one request; global logout (DELETE /auth/sessions) works. - Login throttled; password check constant-time; unknown user indistinguishable from wrong password.
- HS256 secret ≥ 256-bit random (
php artisan lukk:secret); v7 enforces the minimum. - Token responses carry
Cache-Control: no-store, private. - Reuse/family-revoke dispatches
Events\RefreshTokenReused. - Expired/not-yet-valid tokens, and tokens whose
subuser was deleted, rejected at the guard. - BFF: browser holds no token; session cookie
__Host-,SameSite=Strict; proxyOrigin-checks state-changing requests; upstreamSet-CookieandX-Forwarded-*stripped; app-API proxy has a fixed SSRF-safe target. - (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;
amrreflectsotp. - (Passkeys) Challenge server-generated, single-use, origin/RP-ID bound; assertion checks UP/UV + signature + pinned algorithms; sign-count regression rejected but
0never flagged; credential IDs globally unique; public key encrypted at rest;amrreflectswebauthn.
Next: Architecture