--- url: /lukk-docs/introduction.md --- # Introduction lukk is a small, focused authentication system for **first-party** applications — apps where you own both the client and the API, so there's no third party to delegate to. It has two halves that are designed and documented together: * **lukk** (Laravel) issues short-lived **access tokens** (signed JWTs) and long-lived, opaque, **rotating refresh tokens**, with reuse detection and instant revocation. * **lukk-js** (TypeScript / Nuxt) is the client that talks to it — attaching the bearer, refreshing before requests fail, surviving a 401, and driving the 2FA and passkey ceremonies. lukk-js mirrors lukk's HTTP contract in TypeScript and is [conformance-tested against a real lukk instance](/architecture#conformance), so the types you code against can't silently drift from the server. ## Not OAuth lukk is intentionally **not** Passport, Sanctum, or an OAuth2 server. There are no client IDs, redirect URIs, or authorization-code/PKCE flows — that machinery exists to delegate access to third parties, and a first-party app has none. lukk keeps only the patterns that carry their weight: * **Short-lived access JWTs** — stateless, verified on every request. * **Opaque rotating refresh tokens** — rotated on every use, stored only as a hash. * **Reuse detection** — replaying a consumed token revokes the entire session. * **A denylist** — revoke an access token or a whole session instantly. If you need third-party sign-in (users authenticating *your* app against *someone else's* identity provider), that's an OAuth/OIDC problem, and lukk is not the tool. ## The token model | | | |---|---| | **Access token** | An HS256 JWT, valid for 15 minutes. Carries `iss`, `aud`, `sub`, `fid` (refresh family id), `jti`, `iat`, `nbf`, `exp`, with the header `typ=at+jwt`. On every request the algorithm is pinned, `iss`/`aud` are asserted, and the denylist is checked by both `jti` and `fid`. | | **Refresh token** | An opaque, 256-bit random string, valid for 30 days. Returned to the client once and stored only as a `sha256` hash. Rotated on every refresh; replaying one after the grace window revokes the whole token family. | HS256 (a shared secret) is the right default while your app is the only thing verifying its own tokens — no keypair to manage, no JWKS to publish. If an independent service ever needs to verify your tokens, RS256/ES256 + a JWKS endpoint + `kid` rotation are built in behind the same contracts: run `php artisan lukk:keygen`, flip `LUKK_ALGORITHM`, done. See [Deployment → Asymmetric keys](/deployment#asymmetric-keys). ## The packages | Package | What it is | |---|---| | **lukk** | The Laravel package (`Lukk\` namespace). One runtime dependency ([`firebase/php-jwt`](https://github.com/firebase/php-jwt)); optional 2FA and passkeys each add one library, only when enabled. | | **lukk-core** | Framework-agnostic TypeScript: the contract **types**, an auth **client** (`createLukkClient`) that attaches tokens and refreshes on a 401 with single-flight, and **WebAuthn helpers**. No runtime dependencies. | | **lukk-nuxt** | A Nuxt 3/4 module built on `lukk-core`: auto-imported composables, route middleware, the BFF proxy, and the transport wiring. | On Nuxt, install `lukk-nuxt` and never touch `lukk-core` directly. On another framework (or none), use `lukk-core` — see [Using lukk-core](/lukk-core). ## The two transport modes The client speaks to lukk in one of two modes. The mode is a single config value; **your component code is identical either way.** * **`bff`** — a Nitro proxy holds the tokens in a sealed, server-side cookie and forwards requests to lukk. The browser only ever talks to your own origin and never sees a token. Best for SSR or a served SPA. * **`direct`** — the client calls lukk directly. The access token lives in memory; the refresh token lives in lukk's hardened `__Host-` cookie. The only option for a fully static site (SSG). See [Transport Modes](/transport-modes) for the full comparison. ## Requirements **Server:** PHP `^8.3` · Laravel `^12.0 | ^13.0` · `firebase/php-jwt` `^7.0`. **Client:** Node `>= 20` · Nuxt `3` (`>= 3.13`) or `4` (for `lukk-nuxt`). > \[!WARNING] > `firebase/php-jwt` v7 hard-enforces an HMAC secret of at least 256 bits. A too-short `LUKK_SECRET` fails loudly at signing time instead of weakly signing — `php artisan lukk:secret` generates a key that clears the floor. Next: **[How It Works](/how-it-works)**, or jump to **[Installation](/installation)**. --- --- url: /lukk-docs/how-it-works.md --- # How It Works lukk splits authentication into two token types with two very different jobs, and the two halves — the Laravel server and the TypeScript client — cooperate so your application code barely has to think about either. This page walks the full request lifecycle end to end: logging in, attaching a token, verifying it, surviving a 401, refreshing with rotation, and what happens when a stolen token is replayed. The internals of each step live in [Tokens & Rotation](/tokens-and-rotation), [Transport Modes](/transport-modes), and [Architecture](/architecture) — here we stay at the level of the flow. ## The two tokens Everything below turns on the split between them: * **The access token** is a short-lived, signed JWT (HS256, 15 minutes). It's stateless — the server verifies it by checking a signature and a few claims, no database lookup — and it's attached to every request as a bearer. * **The refresh token** is a long-lived, opaque random string (30 days). It's never a JWT and carries no meaning on its own; the server stores only its `sha256` hash. Its only job is to mint a fresh access token when the old one expires, and it's **rotated** — replaced with a new one — every single time it's used. Short-lived-and-stateless plus long-lived-and-revocable is the whole design. See [Tokens & Rotation](/tokens-and-rotation) for the claim set and the rotation algorithm. ## 1. Logging in The client posts credentials to lukk's `/auth/login`. lukk verifies them in constant time (an unknown user runs an equivalent hash check, so a wrong email is indistinguishable from a wrong password), starts a refresh-token **family**, and returns a **token pair**: an access token and a refresh token, plus an `expires_in`. Where those tokens land depends on the client's [transport mode](/transport-modes): * In **`bff`** mode a same-origin Nitro proxy captures the pair and seals it into an encrypted, server-side cookie. The browser receives only an opaque session cookie and never sees a token. * In **`direct`** mode the access token is held in the client's memory and the refresh token rides lukk's hardened `__Host-` cookie set by the server. Either way your component code is identical — the client exposes one `useLukkAuth()` surface over both. ## 2. Attaching the access token On every subsequent request the client attaches the access token as `Authorization: Bearer `. In `direct` mode the client reads it from memory; in `bff` mode the proxy injects it server-side so the browser never handles it. The [`useLukkFetch`](/use-lukk-fetch) composable does this for your own app API too, correctly in the browser, during SSR, and in server routes. ## 3. Verifying a request lukk's guard (`auth:api`, backed by the `lukk-jwt` driver) verifies the access token statelessly: * the **algorithm is pinned** from config and never read from the token header (the alg-confusion defense); * `iss`, `aud`, and `exp`/`nbf` are validated, and the `typ=at+jwt` header is asserted; * the **denylist** is checked by both `jti` (this token) and `fid` (this whole session). If it all passes, `$request->user()` is populated and the request proceeds — no database round-trip for the token itself. ## 4. Hitting a 401 Access tokens are deliberately short-lived, so a `401` is a routine event, not an error. When lukk rejects a request (expired token, or a revoked one), the client catches the `401` and — instead of bubbling it up to your UI — kicks off a refresh. Concurrent 401s are common (SSR fires a burst of requests, or a user has ten tabs). The client collapses them into a **single in-flight refresh** (`singleFlight`): a page that fires ten requests at once triggers one refresh, not ten. In `bff` mode the proxy single-flights its server-side refresh per session for the same reason. ## 5. Refreshing and rotating The client sends the refresh token to `/auth/refresh`. lukk, inside a transaction, looks up the row by hash, confirms it's live, **marks it consumed, and mints a successor** in the same family — the token is rotated, not reused. It returns a fresh access token and a fresh refresh token. The client retries the original request with the new access token, and your UI never sees the interruption. ## 6. Reuse detection Rotation is what makes theft detectable. Because a refresh token is consumed on use, presenting an **already-consumed** token after a short [grace window](/tokens-and-rotation#the-grace-window) is the signature of a stolen token being replayed. lukk responds by revoking the **entire family** — every live access and refresh token for that session — and denylisting it by `fid`, so every access token dies within one 15-minute TTL. It also dispatches a [`RefreshTokenReused`](/events) event so you can alert on it. The grace window is the counterweight: legitimate concurrent refreshes (multiple tabs, SSR + hydration) present the same token nearly simultaneously and must **not** be mistaken for theft, so within the window the straggler is served a fresh token under the same family instead of triggering a revoke. This is why the client single-flights and why lukk keeps `grace_seconds > 0`. ## 7. Revocation Because the denylist is checked on every request, revocation is instant. A logout revokes the current session; `DELETE /auth/sessions` revokes them all. Either way the denylist entry (keyed by `fid`/`jti`) self-evicts when the token it kills would have expired anyway, so revocation costs are proportional to revoked sessions, not to all tokens ever issued. *** That's the whole loop: log in once, ride short access tokens that refresh silently, and a single replayed token takes the whole session down. For the token internals read [Tokens & Rotation](/tokens-and-rotation); for where the tokens physically live, [Transport Modes](/transport-modes); for the code that implements all of it, [Architecture](/architecture). Next: **[Installation](/installation)** --- --- url: /lukk-docs/installation.md --- # Installation lukk has a server half (Laravel) and a client half (Nuxt or another TypeScript app). Set up the server first — it's the source of truth — then point a client at it. ## Server (Laravel) ### Install the package ```bash composer require lukk/lukk ``` Publish the config and the core migration, then run it: ```bash php artisan vendor:publish --tag=lukk-config # config/lukk.php php artisan vendor:publish --tag=lukk-migrations # refresh_tokens migration php artisan migrate ``` > \[!NOTE] > lukk's migrations are **publish-only** — nothing runs until you publish it, the same convention as Sanctum and Passport. Each optional feature ([two-factor](/two-factor-authentication), [passkeys](/passkeys)) is its own publish group, so you only add its schema when you enable the feature. ### Generate the signing secret Access tokens are signed with a 256-bit secret. Generate one into `.env`: ```bash php artisan lukk:secret ``` It behaves like `key:generate`: no flag writes `LUKK_SECRET` (prompting before overwrite), `--force` overwrites silently, `--show` prints it instead. > \[!WARNING] > Treat `LUKK_SECRET` like `APP_KEY` — never commit it. Rotating it invalidates every access token signed with the old value (refresh tokens are opaque and unaffected, so clients recover on their next refresh). ### Configure issuer & audience Stamped into every token and validated on every request: ```dotenv LUKK_ISSUER=https://api.example.com LUKK_AUDIENCE=https://api.example.com ``` Every other setting has a sensible default — see [Configuration](/configuration). ### Wire the guard lukk registers a `lukk-jwt` auth driver. Map a guard to it in `config/auth.php`: ```php 'guards' => [ 'api' => [ 'driver' => 'lukk-jwt', 'provider' => 'users', ], ], ``` Protect routes with `auth:api` as usual: ```php Route::middleware('auth:api')->get('/me', fn (Request $request) => $request->user()); ``` > \[!IMPORTANT] > lukk's own `/auth/*` routes always render JSON `401`/`422`. **Your own `auth:api` routes are not covered automatically:** an unauthenticated request without `Accept: application/json` takes Laravel's guest redirect and — with no `login` route — 500s inside the middleware. Attach lukk's `lukk.force-json` middleware to fix it surgically: > > ```php > Route::middleware(['lukk.force-json', 'auth:api'])->get('/me', fn (Request $r) => $r->user()); > ``` > > (Or send `Accept: application/json` from the client — the lukk-nuxt BFF proxy does this for you.) ### Prepare the User model (optional) The `HasRefreshTokens` trait adds session helpers: ```php use Lukk\Concerns\HasRefreshTokens; class User extends Authenticatable { use HasRefreshTokens; } ``` | Method | Description | |---|---| | `$user->refreshTokens()` | The `HasMany` relationship to the user's refresh tokens. | | `$user->startSession()` | Starts a session, returns a `TokenPair` (access + refresh). | | `$user->revokeAllSessions()` | Revokes every session for the user. | The server is now ready to authenticate. ## Client (Nuxt) > Not on Nuxt? Use the framework-agnostic client — see [Using lukk-core](/lukk-core). ### Install the module ```bash npm i lukk-nuxt # or: pnpm add lukk-nuxt · yarn add lukk-nuxt ``` ```ts // nuxt.config.ts export default defineNuxtConfig({ modules: ['lukk-nuxt'], lukk: { baseURL: 'https://api.example.com/auth', // your lukk auth routes (incl. the prefix) mode: 'bff', // 'bff' (default) or 'direct' user: { endpoint: '/api/me' }, // your app's authenticated user route }, }) ``` > \[!NOTE] > In `bff` mode `baseURL` is used **only on the server** — never exposed to the browser, which talks to the same-origin proxy at `/api/_lukk`. In `direct` mode it's public, since the browser calls lukk itself. ### The user endpoint lukk issues the token; **your app owns the user resource.** Point `user.endpoint` at a route on your own backend that returns the authenticated user — lukk-js calls it (with the access token attached) to populate `useLukkAuth().user`. Leave it unset and `user` stays `null`. See [The User](/user). ### BFF mode: the session secret In `bff` mode the proxy seals the tokens into an encrypted server-side cookie, which needs a secret of **at least 32 characters** (never commit it): ```dotenv NUXT_LUKK_SESSION_PASSWORD=a-long-random-string-of-at-least-32-chars ``` Generate one with `openssl rand -base64 32`. It's the BFF equivalent of `APP_KEY`: it's the confidentiality boundary for the sealed tokens, it must be **identical across every server instance**, and rotating it logs everyone out. `direct` mode has no server-side session, so it needs no secret. ### What the module registers * the composables (`useLukkAuth`, `useLukkTwoFactor`, `useLukkConfirmation`, `useLukkPasskeys`, `useLukkEmailVerification`), auto-imported; * the route middleware `lukk-auth`, `lukk-guest`, `lukk-verified`, `lukk-confirmed`; * a client plugin that restores an existing session on load; * in `bff` mode, the Nitro proxy at `/api/_lukk/**`. Next: **[Authentication](/authentication)** — logging in from the client against your lukk server. --- --- url: /lukk-docs/tokens-and-rotation.md --- # Tokens & Rotation This is the deep dive on lukk's two tokens: what the access JWT contains and how it's verified, and how refresh tokens rotate, detect reuse, and get revoked. It's server-centric — lukk mints and validates the tokens — with notes on how the client experiences each mechanism. For where the tokens physically live in the browser, see [Transport Modes](/transport-modes); for the request-lifecycle overview, [How It Works](/how-it-works). ## The access token The access token is an HS256 JWT, valid for 15 minutes. It carries these claims, with the header `typ=at+jwt`: | Claim | Meaning | |---|---| | `iss` | Issuer — your API's configured URL. | | `aud` | Audience — the API the token is bound to. | | `sub` | Subject — the authenticated user's id. | | `fid` | Refresh **family** id — ties the access token to its refresh-token lineage, so a family revoke can kill it. | | `jti` | Unique token id — the denylist key for this individual token. | | `iat` / `nbf` / `exp` | Issued-at / not-before / expiry. | It's **stateless**: the guard verifies it by checking a signature and these claims, with no database lookup for the token itself. ### Algorithm pinning On every request the signing algorithm is **pinned from config and never read from the token header**. This is the defense against the classic `alg` confusion attacks — an attacker can't downgrade to `alg=none`, and (under asymmetric keys) can't present an HS256 token signed with the public key as the HMAC secret. Alg mismatches are rejected outright. The JWS layer itself is delegated entirely to the audited `firebase/php-jwt`; lukk never hand-rolls encode/verify. ### Claim validation Every request asserts the security-relevant claims: * `iss` and `aud` must match your configuration — a token minted for another service won't verify. * `exp` is required and enforced; `nbf`/`iat` are honored when present. * The `typ=at+jwt` header is **stamped and asserted** — a 2FA/step-up *challenge* token (same key, `iss`, and `aud`) is therefore rejected when presented as a bearer. * The denylist is checked by both `jti` and `fid`, so a single revoked token or a whole revoked session is caught. ### HS256 by default HS256 (a shared secret) is the correct default while your application is the only thing verifying its own tokens — there's no keypair to distribute and no JWKS to publish. RS256/ES256 + a JWKS endpoint + `kid` key rotation are implemented behind the same contracts for the day an independent service must verify your tokens without holding the signing secret; it's a configuration change (`php artisan lukk:keygen`, flip `LUKK_ALGORITHM`), not a rewrite. See [Deployment → Asymmetric keys](/deployment#asymmetric-keys). ## The refresh token The refresh token is an opaque, 256-bit random string, valid for 30 days. It's **not** a JWT and carries no readable claims. It's returned to the client once and stored server-side **only as a `sha256` hash** — never logged, never serialized into any client bundle, never JS-readable. Its sole purpose is to obtain a new access token when the old one expires. Every refresh token belongs to a **family** (`family_id`) that stays stable across a rotation chain. That family id is what links a refresh lineage to the access tokens minted from it (via the `fid` claim), and it's the unit of revocation. ## Rotation Refresh is atomic and reuse-detecting. In pseudocode: ``` POST /auth/refresh (opaque refresh token RT): h = sha256(RT) in a transaction: row = SELECT ... WHERE token_hash = h FOR UPDATE if no row -> 401 invalid if row.revoked_at -> revoke family; 401 (a killed token was replayed) if row.expires_at past -> 401 expired if row.rotated_at: if within grace -> mint a fresh successor sibling, same family (a straggler; no logout) else -> revoke family; 401 (post-grace replay = theft) # happy path: mark row rotated; insert successor in the same family mint a new access token return { access, refresh, expires_in } ``` ```mermaid flowchart TD A[POST /auth/refresh
h = sha256 token] --> B{row for h?} B -- no --> X[401 invalid] B -- yes --> C{revoked?} C -- yes --> K[revoke family + denylist fid
after commit] --> R[401 · RefreshTokenReused] C -- no --> D{expired?} D -- yes --> X2[401 expired] D -- no --> E{already rotated?} E -- no --> H[happy path:
mark rotated, mint successor
same family] --> P[200 new pair] E -- yes --> G{within grace window?} G -- yes --> S[straggler:
mint fresh sibling, same family
no logout] --> P G -- no --> K ``` The family is revoked **after** the transaction commits, never inside it — revoking inside the transaction and then throwing would roll back the revocation while the denylist cache write persisted, leaving an inconsistent state. ## Reuse detection Rotation alone isn't the point; reuse detection is what makes it worth doing. When a token that has **already been consumed** (or already revoked) is presented after the grace window, that's the signature of a stolen token being replayed — so lukk revokes the **entire family** and denylists it by `fid`, killing every live access token for that session within one `access_ttl`. It also dispatches [`RefreshTokenReused`](/events) so you can alert on it. ## The grace window The **grace window** (`grace_seconds`, default 30s) is the counterweight that prevents false positives. Legitimate concurrent refreshes — multiple tabs, SSR + hydration — present the same token nearly simultaneously; within the window the older one is served a fresh access token under the same family rather than being treated as theft. > \[!NOTE] > **Accepted residual.** The grace window is a deliberate trade-off: a token *stolen and replayed within `grace_seconds` of a legitimate refresh* yields a fresh successor on a sibling chain instead of a family revoke — so that race produces a parallel session reuse-detection won't catch until the thief replays a *consumed* token past grace. This is the price of never falsely logging out a direct (non-BFF) client that can't be single-flighted; keep `grace_seconds` as small as your concurrency tolerates. Watch [`RefreshTokenReused`](/events) for the post-grace replays that *are* caught. ### How the client experiences it The client never asks you to manage any of this. On a `401` it calls refresh once and retries the original request, and concurrent 401s are collapsed into a **single in-flight refresh** (`singleFlight`) — a page firing ten requests at once triggers one refresh, not ten. In BFF mode the proxy single-flights its server-side refresh per session for the same reason. Both dovetail with the grace window above, so a rotated refresh token is never replayed into a false family revocation. See [Transport Modes](/transport-modes) for the per-mode details. ## The denylist and revocation Because the denylist is consulted on every request, revocation is instant. It's held in the **cache, not a table** — keyed by `jti` and `fid`, with each entry self-evicting when the token it revokes would have expired anyway. That makes revocation O(revoked sessions) rather than O(all tokens). A logout revokes one session; `DELETE /auth/sessions` revokes them all. ## Database schema ```php Schema::create('refresh_tokens', function (Blueprint $table) { $table->ulid('id')->primary(); $table->foreignId('user_id')->index(); $table->uuid('family_id')->index(); // stable across a rotation chain $table->char('token_hash', 64)->unique(); // sha256(opaque token) $table->ulid('previous_id')->nullable(); // audit chain $table->timestamp('rotated_at')->nullable(); // set when consumed $table->timestamp('revoked_at')->nullable(); // hard kill (logout / reuse cascade) $table->timestamp('expires_at')->index(); $table->timestamps(); }); ``` Next: **[Transport Modes](/transport-modes)** --- --- url: /lukk-docs/transport-modes.md --- # Transport Modes The client speaks to lukk in one of two modes. They differ only in **where the tokens live and who talks to lukk** — your component code is identical either way, because both sit behind the same composables. On the server, each client mode pairs with a lukk [output mode](/configuration#output-mode): **`bff` ↔ body mode**, **`direct` ↔ cookie mode**. ```mermaid flowchart TB subgraph bff [bff — tokens server-side] direction LR B1[Browser] -->|same-origin /api/_lukk| N[Nitro proxy
sealed cookie] -->|Bearer| L1[lukk API] end subgraph direct [direct — tokens in the browser] direction LR B2[Browser] -->|Bearer + __Host- cookie| L2[lukk API] end ``` ## BFF Mode `mode: 'bff'` (the default). A Nitro server route (`/api/_lukk/**`) proxies every auth call to lukk. The tokens are captured on the server and stored in a **sealed, encrypted cookie** — the browser receives only that opaque session cookie and **never sees a JWT or a refresh token**. ```mermaid sequenceDiagram participant Browser participant Proxy as Nitro proxy participant Lukk as lukk API Browser->>Proxy: POST /api/_lukk/login { email, password } Proxy->>Lukk: POST /auth/login Lukk-->>Proxy: { access_token, refresh_token } Note over Proxy: tokens captured + sealed
into the session cookie Proxy-->>Browser: { ok: true } — no token in the body ``` The proxy also refreshes server-side: when a forwarded request comes back `401`, it uses the stored refresh token to mint a new pair, re-seals it, and retries — all without the browser noticing. The proxy also **holds the step-up confirmation token server-side** (it strips it from confirm responses and injects the `X-Lukk-Confirmation` header itself) — so in BFF mode *no* credential, not even the confirmation token, ever reaches the browser. **Why choose it** * The browser holds **no token** (access, refresh, or confirmation), so XSS can't exfiltrate one. * Clean SSR: the server reads the sealed session and hydrates both the authenticated **data** and the auth **state** (`user` / `loggedIn`), so authenticated pages render logged-in on the first paint — no flash, no `` (see [SSR hydration](#ssr-hydration)). * No CORS — the browser only talks to your own origin. **The CSRF trade-off.** Moving tokens server-side trades XSS-exfiltration risk for CSRF risk: the proxy is authenticated by the ambient session cookie. lukk-nuxt closes this — the session cookie is `__Host-lukk-session` (`SameSite=Strict; Secure; HttpOnly; Path=/`, no `Domain`), **and** the proxy rejects any state-changing request whose `Origin` doesn't match your app (a `403`). You don't need to add your own CSRF layer for `/api/_lukk/**`. **What it needs** * A runtime server (Node, an edge runtime, a serverless function) — so it does **not** work for a fully static (SSG) deploy. * A session secret, [`NUXT_LUKK_SESSION_PASSWORD`](/configuration#session-password). * lukk in body mode (`LUKK_COOKIE_MODE=false`, its default), so the proxy receives the refresh token to seal. > \[!NOTE] > **Throttling & `grace_seconds`.** Every user's auth traffic egresses from the BFF server's IP, so lukk's *per-IP* refresh/login [throttles](/configuration#rate-limits) collapse onto one address — raise them for a BFF deployment (and forward `X-Forwarded-For` to lukk if it sits behind your proxy). Keep lukk's `grace_seconds > 0` (its default 30s): the proxy single-flights refresh, but a zero grace window turns any concurrent refresh into a full-family [revocation](/architecture#reuse-detection). > \[!WARNING] > **Keep the sealed session under ~4 KB (a claims budget).** The `__Host-lukk-session` cookie holds the access JWT *plus* the refresh and confirmation tokens, iron-sealed (which inflates the payload ~1.34× on top of a fixed envelope). Per [RFC 6265bis §5.6](https://httpwg.org/specs/rfc6265bis.html#section-5.6) a browser **silently drops** any cookie whose `name`+`value` exceeds **4096 octets** — so if a bloated access token pushes the seal over the line, login appears to succeed but the cookie never persists and every following request is anonymous. This only bites when your backend embeds a large claim set via [`Lukk::tokenClaimsUsing`](/customization) (many roles/permissions/tenant data). Keep custom claims lean — put bulky authorization data behind an API lookup keyed by `sub`, not in the token. lukk-nuxt emits a one-line `console.warn` as the sealed session nears the limit so you catch it in development. ### SSR hydration In BFF mode the server holds the session, so lukk-nuxt hydrates `useLukkAuth().user` / `loggedIn` **during server rendering** — an authenticated page renders logged-in on the first paint, with no logged-out→logged-in flash and no consumer ``. It's **on by default**; disable it with `lukk: { ssrHydrate: false }` (reverting to client-only restore). Per request, on the server, a `session.server` plugin reads the sealed session (read-only — it never mints or slides the cookie), and if the access token is still valid it fetches your `user.endpoint` in-process (the same request-aware path [`useLukkFetch`](/use-lukk-fetch) uses) and seeds the user into the SSR payload. The client then hydrates with `user` already present and skips the redundant restore. Security properties: * **No token in the payload.** Only your app `user` resource is serialized into the HTML; the access/refresh token never leaves the server (the BFF invariant holds). Expose only fields you're comfortable shipping in the page from your `user.endpoint`. * **`no-store` on hydrated renders.** A page that embeds a per-user identity is marked `Cache-Control: no-store`, so a shared cache/CDN can't serve one user's render to another (the sealed cookie header alone does **not** prevent caching — RFC 6265bis §5.6). * **Fails safe.** An anonymous, tampered, or expired-seal request hydrates as logged-out with no side effects (no minted cookie, no 500). An access token that's *expired at render time* is deferred to the client restore rather than refreshed mid-render (refreshing would rotate + re-seal the session during the document response). * **`direct` mode is unaffected** — the access token lives in client memory only, so there's no server session to hydrate from; direct-mode pages stay client-hydrated. > \[!NOTE] > **Additive, but a behavior change from ≤ 0.3.** SSR `useLukkAuth().user` used to be `null` on the server (populated only after client hydration); it is now populated during SSR in BFF mode. If a page special-cased "always anonymous on the server", review it (or set `ssrHydrate: false`). ### Authenticating your own API in BFF The proxy above authenticates the lukk **`/auth`** routes. Your **own** API (and `user.endpoint`) gets no token automatically — the browser has none. Two supported ways (this pairs with lukk's [splitting auth from the API](/deployment#splitting-auth-and-api) topology when the two live on different services): 1. **The app-API proxy** (recommended) — forward `${path}/**` to a fixed Laravel `target`, injecting the bearer server-side: ```ts lukk: { mode: 'bff', api: { path: '/api', target: 'https://api.example.com' }, user: { endpoint: '/api/me' }, // same-origin → authenticated by the proxy } ``` `$fetch('/api/...')` from the browser is now authenticated, the token never leaving the server. SSRF-safe (fixed target), CSRF-checked, strips the inbound cookie/authorization **and any browser-spoofable `X-Forwarded-*` headers** (stamping a trusted client IP so Laravel's per-IP throttling/logging can't be poisoned), strips upstream `Set-Cookie`, marks responses non-cacheable, streams the body, and never proxies `/api/_lukk/**`. > \[!NOTE] > **Transparent refresh.** If the sealed session's access token has already expired, the proxy refreshes it server-side *before* forwarding — sharing the same per-session single-flight as the `/api/_lukk/**` auth proxy, so a concurrent auth call and app-API call rotate the refresh token exactly once (never a reuse-detection family revoke). The rotated session is re-sealed into the cookie and carried through the streamed response. A genuinely revoked session still surfaces naturally: the refresh fails and Laravel sees the (stale) bearer, returning its own `401`. The request body is never buffered. > \[!NOTE] > The trusted IP is the connection peer. If Nitro itself sits behind a load balancer / CDN, that's the LB's IP — configure your trust chain (or have your edge set the real `X-Forwarded-For`) if Laravel needs the true client IP. 2. **Your own server route** — read the token with the auto-imported read-only helper `getLukkAccessToken(event)` (it never sets a cookie, so it's safe on unauthenticated requests): ```ts export default defineEventHandler(async (event) => { const token = await getLukkAccessToken(event) if (!token) throw createError({ statusCode: 401 }) return $fetch('https://api.example.com/me', { headers: { Authorization: `Bearer ${token}` } }) }) ``` To call your app API from a page or `useAsyncData`, use the auth-aware [`useLukkFetch`](/use-lukk-fetch) — a plain `$fetch('/api/...')` forwards no cookie during SSR and 401s. For forms bound to Laravel validation, use [`useLukkForm`](/use-lukk-form). ## Direct Mode `mode: 'direct'`. The client in the browser calls lukk directly — there is no proxy. The access token is kept **in memory** (never in `localStorage`), and the refresh token lives in lukk's hardened `__Host-refresh` cookie (HttpOnly, Secure, `SameSite=Strict`), which the browser sends automatically on refresh. **Why choose it** * No runtime server required — it works for a **fully static site** served from a CDN. * Simpler deploy: there's nothing server-side to run. **What it needs** * lukk in cookie mode (`LUKK_COOKIE_MODE=true`), so the refresh token is delivered as the `__Host-` cookie. * **CORS configured on lukk** for your site's exact origin, with credentials. Because the client sends `credentials: 'include'`, lukk must echo your specific `Origin` and set `Access-Control-Allow-Credentials: true` — a wildcard `Access-Control-Allow-Origin: *` is rejected by the browser when credentials are included. Getting this wrong fails *silently* as a perpetual logged-out loop. (Cross-site cookie delivery also requires lukk's refresh cookie to be `SameSite=None; Secure` if your app and API are on different sites.) Call your own API with [`useLukkFetch()`](/use-lukk-fetch) here too: it attaches the in-memory bearer and single-flights a `401` refresh-and-retry (sharing `$lukk`'s refresh). Because the token is client-only, a **direct**-mode `useLukkFetch` call during SSR has no bearer — fetch your API on the client, or use **BFF** mode when you need SSR-authenticated data. > \[!WARNING] > **The access token is reachable by JavaScript in direct mode.** It lives in client memory (never `localStorage`), but any script on the page — including injected script under XSS — can read it and call the API as the user until it expires. Minimise your XSS surface and set a strict Content-Security-Policy. The token is **not** written during SSR, so it never lands in the hydration payload; keep it that way (don't trigger `login`/`fetchUser` server-side). If you need the browser to hold *no* token at all, use **BFF mode**. > \[!NOTE] > The access token in memory is gone on a full page reload — that's fine. On load, [session restore](/authentication#restore) silently refreshes from the `__Host-` cookie and you're logged back in. ## Which Mode for Which App | Your app | Recommended mode | |---|---| | SSR (Nuxt with a Node/edge server) | **`bff`** | | SPA served by a Node server | **`bff`** | | Static site / SSG (CDN, no server) | **`direct`** | | Prototype hitting a local lukk | either | When in doubt and you have a server, prefer **`bff`** — keeping tokens out of the browser is the stronger default. ## Switching Modes Flip one config value — and update the lukk side to match the [output mode](/configuration#output-mode): ```ts // nuxt.config.ts lukk: { mode: 'direct' } ``` ```dotenv # lukk (.env) — cookie mode for direct, body mode for bff LUKK_COOKIE_MODE=true ``` No component, composable, or page changes. That's the point. > \[!NOTE] > Both modes ride `Secure`, `__Host-`-prefixed cookies that a browser won't persist over plain `http`. For running either mode on `http://localhost`, see [Local Development](/local-development). Next: **[Configuration](/configuration)**. --- --- url: /lukk-docs/configuration.md --- # 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 ```php '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`](/installation#generate-the-signing-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), which populates a `keys` block (`active` kid, `private`, `passphrase`, `public` kid→key map). Under an asymmetric algorithm, `GET {path}/jwks` publishes the public keys as a JWK Set (RFC 7517). See [Deployment → Asymmetric keys](/deployment) for the full block, key-rotation procedure, and the JWKS endpoint. ### Issuer & audience ```dotenv LUKK_ISSUER=https://api.example.com LUKK_AUDIENCE=https://api.example.com ``` The `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](/deployment). ### Token lifetimes ```php '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. | ### Refresh behavior ```php 'grace_seconds' => (int) env('LUKK_GRACE', 30), '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 fresh access token only — see [Authentication → Refreshing tokens](/authentication#refreshing-tokens). | | `leeway` | `5` | Clock-skew tolerance, in seconds, applied when validating the `exp` and `nbf` claims. | ### Rate limits Every throttle lives here, each shaped as `{ max_attempts, decay_seconds }` (login adds a third key, `ip_max_attempts`): ```php 'rate_limits' => [ 'login' => ['max_attempts' => 5, 'decay_seconds' => 60, 'ip_max_attempts' => 30], 'two_factor' => ['max_attempts' => 5, 'decay_seconds' => 60], 'refresh' => ['max_attempts' => 30, 'decay_seconds' => 60], 'passkeys' => ['max_attempts' => 30, 'decay_seconds' => 60], ], ``` | Limit | Default | Keyed on | Notes | |---|---|---|---| | `login` | 5 / 60s (+ `ip_max_attempts` 30) | normalized email + IP | Failures-only: only failed attempts count, a success clears the counter; lockout 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. | | `two_factor` | 5 / 60s | account (`sub`) | Throttles challenge-code guesses for a single account. 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 the passkey login + assertion-options endpoints. | Each maps to a named limiter (`lukk-refresh`, `lukk-passkeys`, `lukk-2fa`) you can also override with your own `RateLimiter::for()`. Tune any of them with the matching env vars — `LUKK_REFRESH_MAX_ATTEMPTS`, `LUKK_2FA_DECAY`, and so on. ### Denylist ```php '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 backs the TOTP replay cache and the passkey/2FA throttles; if it isn't shared, a revoked token can still be honored on another node and replay protection isn't authoritative. ### Output mode ```php '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, `Path=/`, no `Domain`); only the access token and its expiry are in the body. | `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](/local-development). See [Authentication → Output modes](/authentication#output-modes) for the full response shapes, and [Transport Modes](/transport-modes) for which client mode pairs with each (BFF ↔ body mode, direct ↔ cookie mode). ### Guard & provider ```php 'guard' => 'api', 'user_provider' => 'users', ``` | Key | Default | Description | |---|---|---| | `guard` | `api` | The auth guard your app maps to the `lukk-jwt` driver. Used by the package's route middleware. | | `user_provider` | `users` | The `config/auth.php` user provider used to resolve and validate credentials during login. | ### Routes ```php 'routes' => true, 'path' => 'auth', ``` | Key | Default | Description | |---|---|---| | `routes` | `true` | Whether to register the package's built-in routes. Set to `false` to define your own. | | `path` | `auth` | The URI prefix the routes are mounted under (e.g. `/auth/login`). | ### Feature toggles ```php 'features' => [ 'rotation' => true, 'reuse_detection' => true, 'denylist' => true, 'logout_all' => true, 'two_factor' => false, 'passkeys' => false, ], ``` | Feature | Default | Description | |---|---|---| | `rotation` | `true` | Rotate the refresh token on every refresh. | | `reuse_detection` | `true` | Revoke the whole family when a consumed token is replayed. | | `denylist` | `true` | Honor the cache-backed revocation denylist. | | `logout_all` | `true` | Enable the "revoke every session" path. | | `two_factor` | `false` | Enable [two-factor authentication](/two-factor-authentication). Requires `pragmarx/google2fa`. | | `passkeys` | `false` | Enable [passkeys](/passkeys). Requires a WebAuthn library. | > \[!WARNING] > The rotation, reuse-detection, and denylist features are the security core of the package. Disable them only if you fully understand the consequence. ### Two-factor Used only when `features.two_factor` is enabled. See [Two-Factor Authentication](/two-factor-authentication). ```php '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). Do not widen this — it multiplies brute-force odds. | | `recovery_codes` | `8` | How many recovery codes are generated. | | `challenge_ttl` | `300` (5 min) | How long a login challenge token is valid. | ### Confirmation Settings for [step-up confirmation](/confirmation). ```php 'confirm' => [ 'ttl' => (int) env('LUKK_CONFIRM_TTL', 300), 'header' => env('LUKK_CONFIRM_HEADER', 'X-Lukk-Confirmation'), ], ``` | Key | Default | Description | |---|---|---| | `ttl` | `300` (5 min) | How long a confirmation ("sudo") proof remains valid. | | `header` | `X-Lukk-Confirmation` | The request header that carries the confirmation token. Must match the client's [`confirmationHeader`](#confirmationheader). | ### Passkeys Used only when `features.passkeys` is enabled. See [Passkeys](/passkeys). ```php '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', 120), '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` | `120` (2 min) | How long a WebAuthn challenge is valid. | | `user_verification` | `required` | Whether the authenticator must verify the user (biometric/PIN), not just their presence. Default `required` makes passwordless login + step-up phishing-resistant (AAL2). Lower to `preferred` only for authenticators that can't verify the user. One of `required`, `preferred`, `discouraged`. | ## 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](/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). | | `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). | | `confirmationHeader` | `string` | `'X-Lukk-Confirmation'` | Header carrying the step-up token. | | `storage` | `string` | `'cookie'` | BFF token storage backend. | ```ts export default defineNuxtConfig({ modules: ['lukk-nuxt'], lukk: { baseURL: 'https://api.example.com/auth', mode: 'bff', user: { endpoint: '/api/me' }, confirmationHeader: 'X-Lukk-Confirmation', storage: 'cookie', }, }) ``` ### `baseURL` The fully-qualified URL of your lukk auth routes, including lukk's route prefix (`lukk.path`, default `auth`): ```ts 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](/transport-modes). > \[!NOTE] > If `baseURL` is empty the module logs a warning at build time. It is the one option you always set. ### `mode` ```ts 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](/transport-modes) before choosing, and pair it with lukk's [output mode](#output-mode) on the server (`direct` ↔ cookie mode, `bff` ↔ body mode). ### `ssrHydrate` ```ts ssrHydrate: true // default; BFF only ``` In 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 ``. 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](/transport-modes) for the full rationale. Set `false` to keep the client-only restore. 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. ### `user.endpoint` ```ts 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 a `Bearer` header. * **`bff`** — the browser has no token, so this **must be a same-origin path authenticated server-side**: a path under the [`api`](#api-bff-app-api-proxy) proxy (e.g. `/api/me`), or your own route using `getLukkAccessToken(event)`. No header is attached client-side. Response shaping (`user.key`), typing (`LukkUser`), and the verified state are covered on [The User](/user). ### `api` (BFF app-API proxy) ```ts 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); the inbound `Cookie`/`Authorization` + spoofable `X-Forwarded-*` are stripped; upstream `Set-Cookie` is stripped; and `/api/_lukk/**` is never proxied. * **`forceJson`** (default `true`) sets `Accept: application/json` on forwarded requests so a JSON API renders clean `401`/`422` JSON for unauthenticated/validation errors — instead of Laravel's default guest-redirect, which 500s behind a proxy. Set `false` to forward the browser's `Accept` instead — only if a route under `path` legitimately serves a non-JSON response. * **`forwardSetCookie`** (default `[]`) is an allow-list of cookie **names** to pass through from the app API to the browser; everything else is stripped. The sealed session cookie is never forwardable. For a hybrid app whose Laravel API sets its own cookie (a locale, a theme) — see [Transport Modes](/transport-modes). > \[!TIP] > Call the proxied API with [`useLukkFetch()`](/use-lukk-fetch) — a plain `$fetch` forwards no cookie during SSR and silently `401`s. 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`: ```dotenv NUXT_LUKK_SESSION_PASSWORD=a-long-random-string-of-at-least-32-chars ``` Only used in `bff` mode. Treat it like Laravel's `APP_KEY`: secret, and rotating it logs everyone out. ### `confirmationHeader` ```ts confirmationHeader: 'X-Lukk-Confirmation' ``` The HTTP header that carries a [step-up confirmation token](/confirmation). Change it only if you've changed `confirm.header` on the lukk side — the two must match. ### `storage` ```ts storage: 'cookie' ``` The BFF token-storage backend. The default `cookie` is a **stateless sealed cookie** — no server-side store, no Redis, serverless-friendly. You can point it at a [Nitro `useStorage`](https://nitro.build/guide/storage) mount name to keep tokens in a server-side store instead. Ignored in `direct` mode. ### Overriding with environment variables Because the options become Nuxt [runtime config](https://nuxt.com/docs/guide/going-further/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) | Next: **[Authentication](/authentication)**. --- --- url: /lukk-docs/authentication.md --- # Authentication Logging in, refreshing, and logging out — the core session lifecycle, on both halves. The server exposes the `/auth/*` endpoints and mints a `TokenPair`; the client drives them through one composable, `useLukkAuth`. For the authenticated user object itself — the user endpoint, `useLukkAuth().user`, and `UserResource` — see [The User](/user). ## Server (Laravel) ### Endpoints When `lukk.routes` is `true` (the default), the package registers these routes under the `lukk.path` prefix (default `auth`): | Method | Path | Middleware | Purpose | |---|---|---|---| | `POST` | `/auth/login` | login throttle | Exchange email + password for a token pair. | | `POST` | `/auth/refresh` | `throttle:lukk-refresh` | Exchange a refresh token for a rotated pair. | | `POST` | `/auth/logout` | `auth:api` | Revoke the current session. | | `DELETE` | `/auth/sessions` | `auth:api` | Revoke every session for the user. | | `DELETE` | `/auth/sessions/others` | `auth:api` | Revoke every session **except** the current one. | > \[!NOTE] > The login throttle is the per-account failure limiter described in [Configuration → Rate Limits](/configuration#rate-limits), not a route `throttle` middleware. All throttles — login, refresh, two-factor, passkeys — are tunable there. ### Logging in Post credentials to `/auth/login`: ```http POST /auth/login Content-Type: application/json { "email": "taylor@example.com", "password": "secret" } ``` On success you receive a token pair (the exact shape depends on the [output mode](#output-modes)): ```json { "access_token": "eyJ0eXAiOiJhdCtqd3Qi...", "refresh_token": "9f8c1d...", "token_type": "Bearer", "expires_in": 900 } ``` Wrong credentials return `422`. Lukk's login is **constant-time**: an unknown email runs the same hashing work as a wrong password, so neither timing nor response shape reveals which accounts exist. > \[!NOTE] > If the user has confirmed [two-factor authentication](/two-factor-authentication) or you require [passkeys](/passkeys), login returns a challenge instead of tokens. See those pages for the second step. ### Refreshing tokens When the access token nears expiry, exchange the refresh token for a fresh pair: ```http POST /auth/refresh Content-Type: application/json { "refresh_token": "9f8c1d..." } ``` Each refresh **rotates** the token: the response contains a brand-new refresh token, and the old one is consumed. Replaying a consumed token after the grace window revokes the entire session — see [Tokens & Rotation → Reuse detection](/tokens-and-rotation). The grace window (`grace_seconds`, default 30s) exists so concurrent refreshes don't fight. If the same token is presented twice within the window — multiple tabs, or SSR plus hydration — the second call gets a fresh **access** token under the same session, rather than being treated as theft. > \[!NOTE] > In [cookie mode](#output-modes), the refresh token is read from the `__Host-refresh` cookie automatically, so the request body can be empty. The full token lifecycle — a short-lived access token used until it nears expiry, then rotated via the long-lived refresh token: ```mermaid sequenceDiagram participant App as App (client) participant API as lukk API App->>API: POST /auth/login { email, password } API-->>App: 200 { access_token (~15m), refresh_token (~30d) } App->>API: GET /protected · Authorization: Bearer access API-->>App: 200 (guard verifies sig/claims + denylist) Note over App: access token nears expiry App->>API: POST /auth/refresh { refresh_token } API-->>App: 200 { new access_token, new refresh_token } — rotated Note over API: old refresh token consumed;
post-grace replay → whole family revoked ``` ### Logging out All logout routes require a valid access token (`auth:api`): * **`POST /auth/logout`** revokes the current session and denylists its family, killing any access token issued for it within one request. * **`DELETE /auth/sessions`** revokes every session belonging to the user — useful for a "log out everywhere" button. * **`DELETE /auth/sessions/others`** revokes every session except the one making the request — useful after a password change. ### Output modes The `lukk.cookie_mode` option controls where tokens are delivered. From a browser SPA or Nuxt app, the [lukk-js client](#client-nuxt) drives these endpoints for you; its two [transport modes](/transport-modes) pair with the output modes below. **BFF mode (`cookie_mode => false`, default).** Both tokens are returned in the JSON body. This suits a server-side client — such as a Nuxt BFF — that seals the tokens server-side so the browser never sees them. ```json { "access_token": "...", "refresh_token": "...", "token_type": "Bearer", "expires_in": 900 } ``` **Direct browser mode (`cookie_mode => true`).** The refresh token is set in a hardened `__Host-refresh` cookie (HttpOnly, Secure, `Path=/`, no `Domain`), and only the access token is in the body. This suits a browser client talking to the API directly, with no BFF in front of it. ```json { "access_token": "...", "token_type": "Bearer", "expires_in": 900 } ``` See [Configuration → Output Mode](/configuration#output-mode) for the config keys. ### Protecting routes Once the [guard is wired](/installation#wire-the-guard), protect routes with `auth:api` and resolve the user normally: ```php Route::middleware('auth:api')->group(function () { Route::get('/me', fn (Request $request) => $request->user()); Route::get('/projects', [ProjectController::class, 'index']); }); ``` On every request the guard verifies the JWT (pinning the algorithm and asserting `iss`/`aud`/`exp`/`nbf`), then checks the denylist by both `jti` and `fid`. A token that is expired, tampered, denylisted, or whose user has been deleted is rejected with `401`. ### Starting sessions manually You don't have to use the built-in login endpoint. To issue tokens yourself — after a custom registration flow, an impersonation feature, or a social login — call `startSession()` on a user (provided by the [`HasRefreshTokens` trait](/installation#prepare-the-user-model-optional)): ```php $pair = $user->startSession(); $pair->accessToken; // the signed JWT $pair->refreshToken; // the opaque refresh token (shown once) ``` The returned `TokenPair` is a value object; the plaintext refresh token is available only here and is never retrievable again. > \[!NOTE] > On the client, a custom registration form that hits your own route can bind Laravel validation with the [lukk-js form helper](/use-lukk-form). ## Client (Nuxt) ### `useLukkAuth` Everything you need for the common case is on one composable, auto-imported in every component, page, and plugin: ```ts const { user, // Ref — the authenticated user (see /user) loggedIn, // ComputedRef login, // (credentials) => Promise logout, // () => Promise fetchUser, // () => Promise — reload the user initSession, // () => Promise — silent restore (runs automatically) revokeOtherSessions, // () => Promise // two-factor — see /two-factor-authentication: pendingTwoFactor, // ComputedRef verifyTwoFactor, // (code) => Promise verifyRecoveryCode, // (recoveryCode) => Promise } = useLukkAuth() ``` The API is identical in [both transport modes](/transport-modes) — only what happens under the hood differs. Each verb maps to a lukk route; see [the endpoints](#endpoints) above for the server contract. For `user` and `fetchUser`, see [The User](/user). ### Logging in Call `login` with the user's credentials: ```vue ``` On success, the token is persisted and the [user is loaded](/user) — `loggedIn` flips to `true`. A failed login throws a typed [`LukkError`](/lukk-core#errors) (`{ status, message, errors? }`). **Custom login fields.** `email` and `password` are required, but you may pass **extra fields** — a `remember` flag, a captcha token, a tenant — and they reach Laravel as-is (no cast needed; the input type is `LoginInput = LoginCredentials & Record`): ```ts await login({ email: email.value, password: password.value, remember: true, captcha: token.value }) ``` lukk ignores unknown fields on the default login path; to actually *act* on them (or accept a different credential field such as `username`), take over login on the **server** with [`Lukk::authenticateUsing`](/customization) — the closure receives the full request. `twoFactorChallenge` accepts extra fields the same way. > \[!NOTE] > If the user has two-factor authentication enabled, `login` does **not** log them in — it surfaces a challenge (`pendingTwoFactor` becomes `true`) for you to complete. See [Two-Factor Authentication](/two-factor-authentication). ### Logging out ```ts const { logout } = useLukkAuth() await logout() ``` This revokes the session on lukk and clears the local state (access token, user, any pending challenge or confirmation) — even if the network call fails, the client is left logged out. ### Session restore A returning user with a valid refresh token should arrive already logged in. The module registers a client plugin that calls `initSession()` on load, which silently attempts a refresh and, if it succeeds, loads the user: ```mermaid sequenceDiagram participant App as App (on load) participant Lukk as lukk (via mode) App->>Lukk: refresh (uses the stored refresh token / cookie) alt valid session Lukk-->>App: new token pair App->>App: fetch user → loggedIn = true else no session Lukk-->>App: 401 App->>App: stay logged out end ``` You don't normally call `initSession()` yourself — the plugin does. It's exposed for tests and custom boot flows. ### Revoking sessions `revokeOtherSessions()` ends every session **except** the current one — useful after a password change ("log out my other devices"): ```ts const { revokeOtherSessions } = useLukkAuth() await revokeOtherSessions() ``` To end *every* session including the current one, just [log out](#logging-out-1). ### Route middleware The module registers four route middlewares: | Middleware | Effect | |---|---| | `lukk-auth` | Redirects to `/login` when **not** authenticated. | | `lukk-guest` | Redirects to `/` when **already** authenticated (e.g. to keep logged-in users off the login page). | | `lukk-verified` | Redirects a logged-in user with an **unverified email** to `/verify-email`. | | `lukk-confirmed` | Redirects a logged-in user without a recent **step-up confirmation** to `/confirm-password`. | ```vue ``` ```vue ``` `lukk-verified` and `lukk-confirmed` act only on an **authenticated** user, so stack them after `lukk-auth`. They're the client-side redirect; the server's [`lukk.verified`](/email-verification) (409) and [`lukk.confirm`](/confirmation) (423) are the real enforcement. ```vue ``` Next: **[The User](/user)**. --- --- url: /lukk-docs/user.md --- # The User lukk issues the token; **your app owns the user resource.** The server never ships a `/user` route — you expose your own `/api/me`-style endpoint and decide its shape — and the client fetches it to populate `useLukkAuth().user`. This page covers both sides of that contract. ## Server (Laravel) ### Your app owns the user lukk doesn't ship a `/user` route (Sanctum convention). Point a route on **your** backend at the authenticated user — the [`/me` route from Installation](/installation#wire-the-guard) is exactly what the [client](#client-nuxt) fetches to populate `useLukkAuth().user`, so its shape is yours to decide. A bare model already works: ```php Route::get('/me', fn (Request $request) => $request->user())->middleware('auth:api'); ``` An Eloquent model serializes `email_verified_at` (respecting `$hidden`/`$casts`), which the client reads to derive its `verified` state — so make sure your response **includes it** (or a boolean `email_verified`). ### `UserResource` (optional) For a shaped, guaranteed-aligned response, extend the optional base resource `Lukk\Http\Resources\UserResource`. It emits the identifier and a derived `email_verified` boolean (the fields the client reads); override `fields()` to add your own: ```php namespace App\Http\Resources; use Illuminate\Http\Request; class UserResource extends \Lukk\Http\Resources\UserResource { protected function fields(Request $request): array { return [ 'name' => $this->name, 'roles' => $this->roles, ]; } } ``` ```php Route::get('/me', fn (Request $request) => new UserResource($request->user()))->middleware('auth:api'); ``` This emits `{ "data": { "id": …, "email_verified": …, "name": …, "roles": … } }`. A Laravel API Resource **wraps in `data` by default** — the client [auto-unwraps a clean `{ data }`](#response-shape-user-key), so `useLukkAuth().user` is the flat user either way; you can also `JsonResource::withoutWrapping()` if you prefer a bare object. `UserResource` is a **convenience, not a contract** — lukk's actual user contract is still Laravel's `Authenticatable` + the opt-in `MustVerifyEmail`; you never have to use the resource. > \[!NOTE] > Keep this resource **lean**. In a BFF/SSR deployment the user object is serialized into the page's SSR payload (HTML), so only expose fields the UI needs — the endpoint, not the client, is where you prevent over-exposure (OWASP API3:2023). ## Client (Nuxt) ### The current user `useLukkAuth().user` is a reactive ref, populated from your [`user.endpoint`](/configuration#user-endpoint) config. lukk issues the token; your app owns the user, so lukk-js fetches it from your backend. In **direct** mode the access token is attached as a `Bearer`; in **bff** mode the browser has no token, so `user.endpoint` must be a same-origin path authenticated server-side (the [app-API proxy](/transport-modes) or your own route via `getLukkAccessToken(event)`): ```vue ``` Call `fetchUser()` to reload it (e.g. after a profile update). With no `user.endpoint` configured, `user` stays `null` and you can drive `loggedIn` yourself. ### Response shape (`user.key`) ```ts user: { endpoint: '/api/me', key: 'data' } // default ``` lukk **auto-unwraps a Laravel API-Resource wrapper**: a clean `{ "data": {...} }` (with no `meta`/`links`/`errors` envelope) becomes the user object — so a `UserResource` "just works". A `{ "data": null }` response is treated as logged-out. * `key: 'data'` (default) — unwrap the `data` wrapper. * `key: 'user'` (or any string) — unwrap a different wrapper key. * `key: false` — disable unwrapping; store the response verbatim. Prefer keeping the resource **flat and lean** (it ships in the SSR HTML): `JsonResource::withoutWrapping()` / `$wrap = null`, or extend `Lukk\Http\Resources\UserResource`. If you're `loggedIn` but `user` fields are `undefined`, lukk logs a dev warning pointing here. ### Typing the user (`LukkUser`) `useLukkAuth().user` is typed `LukkUser | null`. lukk pre-declares only what it reads (`email_verified_at` / `email_verified`); augment the interface with your own fields: ```ts declare module 'lukk-core' { interface LukkUser { name: string roles: string[] } } ``` ### Verified state The client derives `verified` from the user object — reading `email_verified_at` (or a boolean `email_verified`) off the fetched resource. That's why your endpoint must include it. The verified state drives the [`lukk-verified` middleware](/authentication#route-middleware) and the [email-verification flow](/email-verification). Next: **[Two-Factor Authentication](/two-factor-authentication)**. --- --- url: /lukk-docs/two-factor-authentication.md --- # Two-Factor Authentication lukk ships optional, opt-in two-factor authentication (2FA) using time-based one-time passwords (TOTP) — the codes generated by apps like Google Authenticator and 1Password — with single-use recovery codes as a backup. The server owns enrolment, the challenge, and replay protection; the client drives the ceremony with `useLukkAuth` (completing a 2FA login) and `useLukkTwoFactor` (managing it). > \[!NOTE] > 2FA must be enabled and configured on the server (`features.two_factor`). The client is inert without it. ## Server (Laravel) ### Setup Install the TOTP library, publish and run the migration (it adds columns to your `users` table), and enable the feature: ```bash composer require pragmarx/google2fa php artisan vendor:publish --tag=lukk-two-factor-migrations php artisan migrate ``` ```php // config/lukk.php 'features' => [ 'two_factor' => true, // ... ], ``` Add the `HasTwoFactorAuthentication` trait to your `User` model: ```php use Lukk\Concerns\HasRefreshTokens; use Lukk\Concerns\HasTwoFactorAuthentication; class User extends Authenticatable { use HasRefreshTokens; use HasTwoFactorAuthentication; } ``` The trait manages the `two_factor_secret`, `two_factor_recovery_codes`, and `two_factor_confirmed_at` columns. See [Configuration → Two-Factor](/configuration#two-factor) for the available options. ### How it works 2FA uses a two-step enrolment and a two-step login: * **Enrolment** is `enable` → `confirm`. Enabling provisions a secret and returns the `otpauth://` URI and recovery codes; the user must submit a valid code to `confirm` before 2FA actually activates. This prevents locking a user out with a secret they never successfully scanned. * **Login** is `login` → `two-factor-challenge`. When a user with confirmed 2FA logs in, the login endpoint returns a short-lived **challenge token** instead of tokens; the client exchanges it, plus a code, for the real token pair. ### Endpoints These routes are registered only when `features.two_factor` is enabled. | Method | Path | Middleware | Purpose | |---|---|---|---| | `POST` | `/auth/two-factor` | `auth` + confirm | Begin enrolment → `{ otpauth_uri, recovery_codes }` (shown once). | | `POST` | `/auth/two-factor/confirm` | `auth` + confirm | Activate 2FA after a valid `code`. | | `GET` | `/auth/two-factor/recovery-codes` | `auth` | How many recovery codes remain (a count — never the codes). | | `POST` | `/auth/two-factor/recovery-codes` | `auth` + confirm | Regenerate recovery codes. | | `DELETE` | `/auth/two-factor` | `auth` + confirm | Disable 2FA. | | `POST` | `/auth/two-factor-challenge` | `throttle` | Exchange a `challenge_token` + `code`/`recovery_code` for a token pair. | > \[!NOTE] > The management routes (everything except the challenge) are gated by [step-up confirmation](/confirmation) — the user must have recently re-confirmed their identity. Changing someone's 2FA settings should require fresh proof. ### Enrolling a user 1. The user posts to `/auth/two-factor`. The response contains the provisioning URI (render it as a QR code) and the recovery codes (display them once): ```json { "otpauth_uri": "otpauth://totp/Example:taylor@example.com?secret=...", "recovery_codes": ["aaaa-bbbb", "cccc-dddd", "..."] } ``` 2. The user scans the QR code with their authenticator app and submits a generated code to `/auth/two-factor/confirm`. 2FA is now active. ### Logging in with 2FA When a 2FA-enabled user logs in, `/auth/login` returns a challenge rather than tokens: ```json { "two_factor": true, "challenge_token": "..." } ``` The client submits the challenge with a TOTP `code` (or a `recovery_code`) to `/auth/two-factor-challenge`: ```http POST /auth/two-factor-challenge Content-Type: application/json { "challenge_token": "...", "code": "123456" } ``` This returns the normal [token pair](/authentication#logging-in), carrying the claim `amr: ["pwd","otp"]` to record that two factors were used. The challenge is single-use and short-lived; a wrong code leaves it usable so the user can retry, and the endpoint is throttled per account. ```mermaid sequenceDiagram actor U as User participant App as App (client) participant API as lukk API U->>App: email + password App->>API: POST /auth/login alt 2FA enabled API-->>App: 200 { two_factor: true, challenge_token } Note over App: AWAITING_2FA — no tokens yet U->>App: TOTP code (or recovery code) App->>API: POST /auth/two-factor-challenge { challenge_token, code } alt code correct API-->>App: 200 { access_token, refresh_token } · amr ["pwd","otp"] Note over App: AUTHENTICATED else wrong code API-->>App: 422 — challenge still valid, retry (throttled per account) end else 2FA disabled API-->>App: 200 { access_token, refresh_token } · amr ["pwd"] end ``` ### Recovery codes Recovery codes let a user authenticate if they lose their device. Each code works **once**. Submit one as `recovery_code` (instead of `code`) to `/auth/two-factor-challenge`. Users can regenerate the full set at any time via `/auth/two-factor/recovery-codes`, which invalidates the old codes. ### Security notes * The TOTP secret is stored **encrypted** (it must be reversible to verify codes), while recovery codes are **salted and hashed** and shown only once. * A TOTP code cannot be replayed within its 30-second window — accepted codes are cached and rejected on reuse. * The verification window is ±1 step and should not be widened (see [Configuration](/configuration#two-factor)). > \[!WARNING] > TOTP is **not phishing-resistant**. A real-time attacker-in-the-middle (such as Evilginx) can relay a code and steal the session. For phishing-resistant authentication, use [passkeys](/passkeys). ## Client (Nuxt) Two-factor authentication has two halves on the client: **completing a login** when a user has 2FA enabled, and **managing** 2FA (turning it on, off, recovery codes). The first lives on `useLukkAuth`; the second on `useLukkTwoFactor`. ### The login challenge When a 2FA user logs in, lukk returns a **challenge** instead of tokens. `login` surfaces this by flipping `pendingTwoFactor` to `true` rather than completing — you show a code input and call `verifyTwoFactor`: ```mermaid sequenceDiagram participant App participant Lukk as lukk App->>Lukk: login { email, password } Lukk-->>App: { two_factor: true, challenge_token } Note over App: pendingTwoFactor = true App->>Lukk: verifyTwoFactor(code) Lukk-->>App: token pair → logged in ``` ```vue ``` The pending challenge is held for you; `verifyTwoFactor` completes it, persists the tokens, and loads the user. ### Recovery codes at login If the user has lost their authenticator, accept a recovery code instead: ```ts const { verifyRecoveryCode } = useLukkAuth() await verifyRecoveryCode('a1b2c3d4-...') ``` Recovery codes are single-use; lukk consumes the code as it completes the challenge. ### Managing 2FA `useLukkTwoFactor` handles enrolment and teardown: ```ts const { enable, // () => Promise<{ otpauth_uri, recovery_codes }> confirm, // (code) => Promise disable, // () => Promise recoveryCodeCount, // () => Promise<{ remaining, total }> regenerateRecoveryCodes, // () => Promise<{ recovery_codes }> } = useLukkTwoFactor() ``` > \[!NOTE] > These actions are sensitive, so lukk gates them behind [step-up confirmation](/confirmation). Earn a confirmation first (`useLukkConfirmation`) and the client attaches it automatically — otherwise lukk responds `423`. ### Enrolment A typical enable-2FA flow: ```ts const { confirmPassword } = useLukkConfirmation() const { enable, confirm } = useLukkTwoFactor() // 1. Step up. await confirmPassword(currentPassword) // 2. Begin enrolment — show the QR code and the recovery codes ONCE. const { otpauth_uri, recovery_codes } = await enable() // render otpauth_uri as a QR code; have the user store recovery_codes // 3. Confirm with the first code from their authenticator app. await confirm(totpCode) ``` `recoveryCodeCount()` returns a safe count for a settings screen (never the codes themselves), and `regenerateRecoveryCodes()` issues a fresh set — both behind confirmation. > \[!WARNING] > Treat `otpauth_uri` (it contains the TOTP secret) and `recovery_codes` as **authenticator secrets**. Render them once, then drop them — never put them in `useState`/a store (they'd serialize into the SSR payload), `localStorage`, logs, or analytics. Next: **[Passkeys](/passkeys)** --- --- url: /lukk-docs/passkeys.md --- # Passkeys lukk supports passkeys (WebAuthn / FIDO2) for **passwordless, phishing-resistant** login. A passkey is a public-key credential bound to your domain and stored on the user's device or password manager; logging in proves possession of the private key, which a phishing site can never obtain. The server owns the relying-party config, the challenges, and the credential store; the client's `useLukkPasskeys` drives the browser `navigator.credentials` ceremony. > \[!NOTE] > Passkeys must be enabled on the server (`features.passkeys`, plus an `rp_id` and `origins`). WebAuthn requires a secure context (HTTPS, or `localhost`). ## Server (Laravel) ### Setup Install the WebAuthn library (the default ceremony adapter wraps it), publish and run the migration, enable the feature, and set your relying-party configuration: ```bash composer require web-auth/webauthn-lib php artisan vendor:publish --tag=lukk-passkey-migrations php artisan migrate ``` ```php // config/lukk.php 'features' => [ 'passkeys' => true, // ... ], 'passkeys' => [ 'rp_id' => 'example.com', // registrable domain shared by app + api 'origins' => ['https://app.example.com'], // your front-end origin(s) ], ``` See [Configuration → Passkeys](/configuration#passkeys) for every option. > \[!NOTE] > The default adapter, `Passkeys\SpomkyWebAuthnCeremony`, works out of the box. To use a different WebAuthn library, rebind `Contracts\WebAuthnCeremony`. ### Endpoints These routes are registered only when `features.passkeys` is enabled. | Method | Path | Middleware | Purpose | |---|---|---|---| | `POST` | `/auth/passkeys/registration-options` | `auth` + confirm | Get a registration challenge. | | `POST` | `/auth/passkeys` | `auth` + confirm | Verify the attestation and store the credential. | | `POST` | `/auth/passkeys/login-options` | `throttle` | Get an assertion challenge → `{ ceremony_id, options }`. | | `POST` | `/auth/passkeys/login` | `throttle` | Verify the assertion → token pair (`amr: ["webauthn"]`). | | `POST` | `/auth/confirm-passkey` | `auth` | Satisfy [step-up confirmation](/confirmation) with a passkey. | | `GET` | `/auth/passkeys` | `auth` | List the user's credentials. | | `DELETE` | `/auth/passkeys/{id}` | `auth` + confirm | Revoke a credential. | ### Registering a passkey Registration happens while the user is logged in (and has [confirmed](/confirmation) their identity): 1. The client requests options from `/auth/passkeys/registration-options` and passes them to the browser's `navigator.credentials.create()`. 2. The browser returns an attestation, which the client posts to `/auth/passkeys`. lukk verifies it and stores the credential. ```mermaid sequenceDiagram participant App as App (client) participant B as Browser + Authenticator participant API as lukk API App->>API: POST /auth/passkeys/registration-options (auth + confirm) API-->>App: { challenge, rp, user, ... } (challenge cached per user) App->>B: navigator.credentials.create(options) B-->>App: attestation (new credential + public key) App->>API: POST /auth/passkeys { credential } Note over API: verify attestation vs cached challenge,
rp_id + origin; store credential (COSE key encrypted) API-->>App: 204 No Content ``` ### Logging in with a passkey Login is fully passwordless: 1. The client requests options from `/auth/passkeys/login-options`. The response includes an opaque `ceremony_id` and the WebAuthn `options`; pass the options to `navigator.credentials.get()`. 2. The browser returns an assertion, which the client posts to `/auth/passkeys/login` along with the `ceremony_id`. lukk verifies the signature and returns the normal [token pair](/authentication#logging-in), carrying `amr: ["webauthn"]`. ```mermaid sequenceDiagram participant App as App (client) participant B as Browser + Authenticator participant API as lukk API App->>API: POST /auth/passkeys/login-options API-->>App: { ceremony_id, options.challenge } (challenge cached by ceremony_id) App->>B: navigator.credentials.get(options) B-->>App: assertion (signed by the private key) App->>API: POST /auth/passkeys/login { ceremony_id, credential } Note over API: pull+verify challenge (single-use), origin/rp_id,
signature vs stored public key, sign-count; resolve user API-->>App: 200 { access_token, refresh_token } · amr ["webauthn"] ``` The challenge is server-generated, single-use, and held server-side (in the cache) — it never travels inside a JWT. ### Managing passkeys `GET /auth/passkeys` lists the current user's credentials, and `DELETE /auth/passkeys/{id}` revokes one (behind step-up confirmation). ### Split-domain (BFF) deployments When your front-end and API live on different subdomains (`app.example.com` and `api.example.com`), set: * **`rp_id`** to the registrable domain they share — `example.com`, **not** `api.example.com`. * **`origins`** to include the front-end origin — `https://app.example.com` — because the browser reports the front-end's origin, not the API's. ### Security notes * Credential IDs are globally unique, and the COSE public key is **encrypted at rest**. * A regressing signature counter is rejected and dispatches `Events\PasskeyCloneDetected`, but a `0` counter is never flagged — synced passkeys (iCloud, Google, 1Password) always report `0`. * `rp_id` and `origins` are **required** when passkeys are enabled — lukk throws on an empty value rather than fall back to a weak default. * By default lukk **requires user verification** (`user_verification` → `required`) — the authenticator must verify the user (biometric/PIN), not just their presence. Passwordless login and `confirm-passkey` step-up are single-factor (possession), so enforcing UV makes them phishing-resistant, AAL2-style. Lower it to `preferred` only if you must support authenticators that can't verify the user (see [Configuration](/configuration#passkeys)). * Passkey storage sits behind `Contracts\PasskeyRepository` (`passkeys` table) and is swappable. > \[!WARNING] > Passkeys are only as phishing-resistant as your weakest fallback. In lukk's default model, password login is always available — so deleting all passkeys can never lock a user out. For a **passwordless-only** deployment, guard the last credential at your application layer and ensure any recovery path is itself phishing-resistant; otherwise an attacker can downgrade to the weaker method. ## Client (Nuxt) `useLukkPasskeys` drives the browser ceremony (`navigator.credentials`) and the base64url (de)serialization for you — you just `await` a verb. > \[!IMPORTANT] > Passkeys are phishing-resistant only because the authenticator binds each assertion to the **browser-facing origin**. Set lukk's `rp_id` to your app's registrable domain and `origins` to the exact origin in the address bar — **not** the lukk API host. In BFF mode the browser talks to your app, so the RP is your app's origin, even though the API lives elsewhere. Avoid wildcard origins. See [Split-domain (BFF) deployments](#split-domain-bff-deployments) above for setting `rp_id`/`origins` on the server. ### `useLukkPasskeys` ```ts const { register, // (name?) => Promise login, // () => Promise — passwordless, then loads the user confirm, // () => Promise — step-up via passkey list, // () => Promise<{ passkeys: PasskeySummary[] }> remove, // (credentialId) => Promise } = useLukkPasskeys() ``` ### Registering a passkey Registration is a sensitive action, so it sits behind [step-up confirmation](/confirmation). Confirm first, then register: ```mermaid sequenceDiagram participant App participant Browser as navigator.credentials participant Lukk as lukk App->>Lukk: passkey registration options Lukk-->>App: creation options (challenge, rp, user) App->>Browser: create(options) Browser-->>App: attestation credential App->>Lukk: register passkey (credential, name) Lukk-->>App: 204 ``` ```ts const { confirmPassword } = useLukkConfirmation() const { register } = useLukkPasskeys() await confirmPassword(currentPassword) // step up await register('My MacBook') // name is optional ``` The browser prompts the user to create the passkey; `register` handles the options, the ceremony, and posting the result to lukk. ### Passwordless login `login()` runs the assertion ceremony and, on success, persists the tokens and loads the user — no email or password: ```vue ``` A user can register a passkey on one device and sign in with it on another (synced passkeys "just work" — lukk never flags a zero sign-count). ### Managing passkeys List the user's passkeys for a settings screen, and remove one by its credential id: ```ts const { list, remove } = useLukkPasskeys() const { passkeys } = await list() // passkeys: { id, name, last_used_at }[] — never the key material await remove(passkeys[0].id) ``` > \[!NOTE] > Removing a passkey is a sensitive action and is gated behind [confirmation](/confirmation) on the lukk side. ### Step-up with a passkey A user can also satisfy [step-up confirmation](/confirmation) with a passkey instead of their password — convenient, and phishing-resistant: ```ts const { confirm } = useLukkPasskeys() await confirm() // runs an assertion → stores a confirmation token ``` After `confirm()`, the next sensitive action (managing 2FA, registering another passkey) is authorized, exactly as if they'd re-entered their password. See [Confirmation](/confirmation). Next: **[Email Verification](/email-verification)** --- --- url: /lukk-docs/email-verification.md --- # Email Verification lukk ships first-party email verification that fits the stateless-JWT model: a **signed link** the user clicks from their inbox, a **resend** endpoint, and a gate for routes that require a verified address. It's opt-in and rides Laravel's framework defaults — there's **no lukk migration**. On the client, `useLukkEmailVerification` owns the resend and the post-redirect sync; the verification click itself happens in the browser, straight from the email. > \[!NOTE] > Email verification must be enabled on the server (`features.email_verification`). The client is the driver for it. ## Server (Laravel) ### How it works Verification state is Laravel's own `users.email_verified_at` column, and your user model implements `Illuminate\Contracts\Auth\MustVerifyEmail` — the same contract Laravel's `verified` middleware and `Verified` event already use. lukk owns the **link** and the **gate**, not the storage: 1. Your app creates the user and triggers the verification email (Laravel's `Registered` event, or `$user->sendEmailVerificationNotification()`). 2. lukk points that notification at a **signed, expiring** URL on its own route (`GET /auth/email/verify/{id}/{hash}`). 3. The user clicks it. lukk validates the signature + the `{id}`/`{hash}` binding, marks the email verified, fires `Illuminate\Auth\Events\Verified`, and **redirects to your SPA** (or returns `204` to a JSON client). The verify link is a **browser navigation, not an XHR** — that's why the signature is the authority (no session or bearer needed) and why the endpoint lives outside lukk's JSON-forcing group so it can redirect. ### Setup Your user model must implement `MustVerifyEmail` (Laravel's default `App\Models\User` already `use`s the trait — just add the interface), and your `users` table must have the framework-default `email_verified_at` column (it does, in a stock Laravel app). Then enable the feature: ```php // app/Models/User.php use Illuminate\Contracts\Auth\MustVerifyEmail; class User extends Authenticatable implements MustVerifyEmail { /* ... */ } ``` ```php // config/lukk.php 'features' => [ 'email_verification' => true, // ... ], 'email_verification' => [ 'frontend_url' => env('LUKK_VERIFY_URL'), // e.g. https://app.example.com/verify-email 'expire' => 60, // signed-link validity, minutes 'block_unverified_login' => false, // see "Blocking unverified login" ], ``` No migration to publish — `email_verified_at` is a Laravel default. ### Endpoints These routes are registered only when `features.email_verification` is enabled. | Method | Path | Middleware | Purpose | |---|---|---|---| | `GET` | `/auth/email/verify/{id}/{hash}` | `signed` + throttle | The email-link target. Verifies, then redirects to `frontend_url` (browser) or returns `204` (JSON client). | | `POST` | `/auth/email/verification-notification` | `auth` + throttle | Resend the verification link to the authenticated user (`202`). | Both are throttled by the `lukk-email-verification` limiter (`rate_limits.email_verification`). ### Sending the first email Registration is your app's job (lukk is not a registration package). After creating the user, trigger the notification the way you already would: ```php event(new \Illuminate\Auth\Events\Registered($user)); // or $user->sendEmailVerificationNotification(); ``` Because the feature is on, lukk has repointed Laravel's `VerifyEmail` notification at its signed route, so the link in the email lands on lukk's endpoint and bounces the user back to your `frontend_url`. Your app's mail template and styling are unchanged. ### Gating routes Attach the `lukk.verified` middleware to any route that needs a verified email: ```php Route::middleware(['auth:api', 'lukk.verified'])->group(function () { // ...routes that require a verified email }); ``` An unverified user gets a **409 Conflict** (distinct from a plain authz `403`, so your client can prompt "verify your email" specifically). The check reads the user's current `hasVerifiedEmail()` each request — never a token claim — so a user who just verified is unblocked without re-logging-in. ### Blocking unverified login By default an unverified user **logs in normally** and you gate the sensitive routes (`lukk.verified`) — the SPA-friendly model (show a "verify your email" banner, allow resend). If you'd rather refuse login outright, set: ```php 'email_verification' => ['block_unverified_login' => true], ``` Now login returns **403** for an unverified `MustVerifyEmail` user and issues no tokens. The check runs only *after* a successful credential check, so it never affects the constant-time unknown-user / wrong-password path. ### Split-domain (SPA / BFF) The email link points at the **API** and redirects to your **SPA** (`frontend_url`), so it works in both direct and BFF deployments without a cross-origin round-trip: * The user clicks the link → the browser navigates to the API → lukk verifies → redirects to `https://app.example.com/verify-email?verified=1`. * Your SPA verify page then refreshes the session / reloads the user so the "unverified" UI clears. > \[!NOTE] > **Exposing the verified state to the client.** the client reads `email_verified_at` (or a boolean `email_verified`) off your `user.endpoint` response to drive its `verified` state — so make sure your user resource **includes** that field. The optional [`Lukk\Http\Resources\UserResource`](/user) emits a derived `email_verified` boolean for you; a bare Eloquent model already serializes `email_verified_at`. ### Security notes * The link is a **signed, temporary URL** (HMAC over your `APP_KEY`, expiring per `expire`), bound to the user's current email via the `sha1(email)` hash — so a tampered link, an expired link, or a link for an email that has since changed all fail (`403`). * Verification is **idempotent** — a double-clicked link marks once and fires `Verified` once. * The gate is **fail-fresh**: `lukk.verified` reads `hasVerifiedEmail()` off the resolved user, not a JWT claim, so it can't be stale. * No secret is ever placed in a token or logged. ## Client (Nuxt) `useLukkEmailVerification()` owns the two things the **client** does — resend the link, and reflect the user's verified state — while the verification click itself happens in the browser, straight from the email. ### The flow Verification is a **browser navigation, not an XHR**: the link in the email points at lukk's signed API route, which verifies and then **redirects back to your SPA** (lukk's `email_verification.frontend_url`). So the client never posts the verification itself — it only: 1. **Resends** the link (`sendVerificationEmail()`), and 2. **Re-syncs** the user when they land back on your verify page (`syncAfterVerify()`), so `verified` flips and any "verify your email" banner clears. This sidesteps the cross-origin-signature problem a fetch-relay through the BFF proxy would hit, and works identically in `direct` and `bff` modes. ### The composable ```ts const { verified, sending, sendVerificationEmail, syncAfterVerify } = useLukkEmailVerification() ``` | Member | Type | What it is | |---|---|---| | `verified` | `ComputedRef` | Whether the loaded user's `email_verified_at` is set. | | `sending` | `Ref` | True while a resend is in flight — bind a button's `disabled` to it. | | `sendVerificationEmail()` | `() => Promise` | Resend the link to the current user (a no-op server-side if already verified; throttled). | | `syncAfterVerify()` | `() => Promise` | Reload the user (used on the verify callback page). | `verified` reads the same `useLukkAuth().user` you already load, so your `user.endpoint` must expose `email_verified_at` for it to reflect reality. ```vue ``` ### The verify callback page Point lukk's `email_verification.frontend_url` at a page in your app (e.g. `/verify-email`). When the email link bounces the user here (with `?verified=1`), reload the user so the app reflects the new state: ```vue ``` ### Gating pages To require a verified email before a page renders, use the **`lukk-verified`** route middleware (stack it after `lukk-auth`) — it redirects a logged-in, unverified user to `/verify-email`: ```ts definePageMeta({ middleware: ['lukk-auth', 'lukk-verified'] }) ``` Or branch on `verified` yourself, and lean on the server as the real enforcement: lukk's [`lukk.verified`](#gating-routes) middleware returns a **409** for unverified users, so an app-API call through [`useLukkFetch`](/use-lukk-fetch) to a gated route surfaces that status for you to handle. Next: **[Confirmation](/confirmation)** --- --- url: /lukk-docs/password-reset.md --- # Password Reset lukk ships first-party password reset that fits the stateless-JWT model: a **reset link** the user requests when they're locked out, a page in your SPA that collects the new password, and — by default — a **revoke of every existing session** on success, so a session that predates the reset can't survive it. It's opt-in and rides Laravel's own password broker — there's **no lukk migration**. On the client, `useLukkPasswordReset` owns both steps: requesting the link and submitting the new password. > \[!NOTE] > Password reset must be enabled on the server (`features.password_reset`). Both endpoints are **public** — the user is logged out — so there's no bearer token involved. ## Server (Laravel) ### How it works Reset state is Laravel's own `password_reset_tokens` table, driven through the framework's **password broker** (`Password::broker()`). Your user model implements `Illuminate\Contracts\Auth\CanResetPassword` — the same contract Laravel's built-in reset already uses. lukk owns the **link target** and the **session revocation**, not the storage: 1. The user submits their email to `POST /auth/forgot-password`. lukk asks the broker to mint a token and email a reset link — and **always** returns a generic `200`, whether or not the email is registered (no user enumeration). 2. lukk points Laravel's `ResetPassword` notification at your SPA (`password_reset.frontend_url`), appending `?token=…&email=…`. 3. The user opens the link, enters a new password, and your page POSTs `{ token, email, password, password_confirmation }` to `POST /auth/reset-password`. lukk verifies the token through the broker, sets the new password, fires `Illuminate\Auth\Events\PasswordReset`, and — unless you've disabled it — **revokes every existing session** (refresh families + denylist). There is **no auto-login**: reset succeeds, then the user logs in with their new password. That keeps the reset flow and the login flow (and its 2FA challenge) cleanly separated. ### Setup Your user model must implement `CanResetPassword` (Laravel's default `App\Models\User` already `use`s the `Notifiable` trait and satisfies the contract via `Authenticatable`), you need the framework-default `password_reset_tokens` table, and a configured `auth.passwords` broker (both ship in a stock Laravel app). Then enable the feature: ```php // config/lukk.php 'features' => [ 'password_reset' => true, // ... ], 'password_reset' => [ 'frontend_url' => env('LUKK_RESET_URL'), // e.g. https://app.example.com/reset-password 'revoke_sessions' => true, // kill existing sessions on reset (recommended) 'broker' => null, // null = your app's default auth.passwords broker ], ``` Set `broker` (or `LUKK_RESET_BROKER`) only if you reset against a **non-default** `auth.passwords` broker — e.g. a separate admin guard with its own token table. `null` uses `config('auth.defaults.passwords')`. The token's **lifetime** and **per-email throttle** come from the broker, not lukk — tune them in `config/auth.php`: ```php // config/auth.php 'passwords' => [ 'users' => [ 'provider' => 'users', 'table' => 'password_reset_tokens', 'expire' => 60, // token validity, minutes 'throttle' => 60, // seconds between link requests for one email ], ], ``` No migration to publish — `password_reset_tokens` is a Laravel default. ### Endpoints These routes are registered only when `features.password_reset` is enabled. Both are public and throttled by the `lukk-password-reset` limiter (`rate_limits.password_reset`, a per-IP guard). | Method | Path | Middleware | Purpose | |---|---|---|---| | `POST` | `/auth/forgot-password` | throttle | Email a reset link. Body `{ email }`. Always `200` (no enumeration). | | `POST` | `/auth/reset-password` | throttle | Set the new password. Body `{ token, email, password, password_confirmation }`. `200` on success, `422` on a bad/expired token or a weak/mismatched password. | The new password is validated with Laravel's `Illuminate\Validation\Rules\Password::defaults()`, so you can tune complexity rules app-wide the usual way. ### Session revocation The lukk-specific value-add: on a successful reset, `revoke_sessions` (default `true`) runs `RevokeAllSessions` for the user — revoking every refresh-token family and denylisting outstanding access tokens. A password reset almost always means "I lost control of this account," so **killing the pre-existing sessions is the safe default** (an attacker holding a stolen refresh token is logged out the moment the real owner resets). Set it to `false` only if you have a specific reason to keep other sessions alive across a reset: ```php 'password_reset' => ['revoke_sessions' => false], ``` ### Security notes * **No user enumeration.** `forgot-password` returns the same `200` for a registered and an unknown email, and the per-email `throttle` is enforced by the broker — so the endpoint can't be used to probe which addresses have accounts. `reset-password` is hardened the same way: every failure (unknown user, bad/expired token, throttled) returns one **generic** `422`, so it never reveals whether an email is registered either. * **Single-use, expiring tokens.** The reset token is broker-managed: hashed at rest, consumed on success, and invalid after `expire` minutes. * **Flatten the timing.** The response body is identical for known and unknown emails, but a registered address does extra work (mint a token, send the mail) before responding. Run mail on a queue (`QUEUE_CONNECTION` other than `sync`, or a `ShouldQueue` notification) so `forgot-password` returns before the email is sent and the two paths take the same time. * **Sessions die by default.** `revoke_sessions` closes the "attacker keeps their session after the owner resets" hole — see above. * **No auto-login, no token leak.** Reset never mints tokens; the user re-authenticates through the normal login flow. Nothing secret is placed in the redirect URL beyond the broker's own reset token. ## Client (Nuxt) `useLukkPasswordReset()` owns both steps of the flow — requesting the link and submitting the new password — with a `sending`/`resetting` flag for each so you can disable buttons while a request is in flight. Both calls route through your configured transport (BFF proxy or direct), identically. ### The flow 1. On your "forgot password" page, call `sendResetLink(email)`. It always resolves (even for an unknown address — the server doesn't enumerate), so show a generic "check your inbox" message regardless. 2. The email link lands on your SPA reset page carrying `?token=…&email=…`. Read those from the route, collect a new password, and call `reset({ token, email, password, password_confirmation })`. 3. On success, send the user to your login page — there's **no auto-login**; they sign in with the new password (and lukk has revoked any pre-existing sessions by default). ### The composable ```ts const { sending, resetting, sendResetLink, reset } = useLukkPasswordReset() ``` | Member | Type | What it is | |---|---|---| | `sending` | `Ref` | True while the reset-link request is in flight — bind a button's `disabled` to it. | | `resetting` | `Ref` | True while the reset submission is in flight. | | `sendResetLink(email)` | `(email: string) => Promise` | Ask lukk to email a reset link. Always resolves (no enumeration; throttled). | | `reset(input)` | `(input: ResetPasswordInput) => Promise` | Submit the token, email, and new password. Rejects with a `LukkError` (`422`) on a bad token or weak/mismatched password. | ### The request page ```vue ``` ### The reset page Point lukk's `password_reset.frontend_url` at a page in your app (e.g. `/reset-password`). Read `token` + `email` from the query, collect the new password, and submit: ```vue ``` > \[!NOTE] > Because a successful reset **revokes existing sessions** by default, any other logged-in device is signed out — the reset page's own session (if any) included. That's intentional: route to login afterwards. Next: **[Confirmation](/confirmation)** --- --- url: /lukk-docs/confirmation.md --- # Confirmation (Sudo Mode) Some actions are sensitive enough that a valid session isn't sufficient — changing 2FA, managing passkeys, deleting an account — you want proof that the *person* is still there. lukk provides **step-up confirmation**: a short-lived "sudo" window, modeled on GitHub's, that the user enters by re-confirming a credential (a password or a passkey). Sensitive routes then require that proof. The server issues and verifies the token; the client's `useLukkConfirmation` earns it and auto-attaches the header. > \[!NOTE] > The client earns the token and auto-attaches the header, so gated actions just work. ## Server (Laravel) ### How it works 1. The user re-confirms with a **password** or a **passkey** and receives a short-lived `confirmation_token`. 2. The client sends that token in a request header (default `X-Lukk-Confirmation`) on subsequent sensitive requests. 3. The `lukk.confirm` middleware checks for a valid, fresh token. If it is missing or expired, the route returns **423 Locked**. The window length is `confirm.ttl` (default 5 minutes). lukk's own [two-factor](/two-factor-authentication) and [passkey](/passkeys) management routes are protected this way. ```mermaid sequenceDiagram participant App as App (client) participant API as lukk API App->>API: POST /auth/confirm-password { password }
Authorization: Bearer access API-->>App: 200 { confirmation_token } (~5 min, bound to this user) App->>API: DELETE /auth/two-factor
Bearer access + X-Lukk-Confirmation: token Note over API: lukk.confirm verifies the token
(fresh + subject == current user) API-->>App: 204 — sensitive action allowed Note over App: without a valid confirmation header → 423 Locked ``` ### Earning confirmation #### With a password ```http POST /auth/confirm-password Authorization: Bearer Content-Type: application/json { "password": "secret" } ``` ```json { "confirmation_token": "..." } ``` #### With a passkey If [passkeys](/passkeys) are enabled, a passkey assertion earns the same token, so passkey-only users can step up too: ```http POST /auth/confirm-passkey Authorization: Bearer ``` Both endpoints return the same kind of `confirmation_token` — the credential used is interchangeable. ### Gating your own routes Apply the `lukk.confirm` middleware to any route that should require a fresh confirmation — account deletion, an email change, revealing an API key, and so on: ```php Route::delete('/account', [AccountController::class, 'destroy']) ->middleware(['auth:api', 'lukk.confirm']); ``` The client then attaches the confirmation token to the request: ```http DELETE /account Authorization: Bearer X-Lukk-Confirmation: ``` A request that reaches a gated route without a valid, fresh token receives `423 Locked`. Your front-end should respond to a `423` by prompting the user to re-confirm, then retrying. ### Configuration ```php // config/lukk.php 'confirm' => [ 'ttl' => (int) env('LUKK_CONFIRM_TTL', 300), 'header' => env('LUKK_CONFIRM_HEADER', 'X-Lukk-Confirmation'), ], ``` | Key | Default | Description | |---|---|---| | `ttl` | `300` (5 min) | How long a confirmation token remains valid. | | `header` | `X-Lukk-Confirmation` | The header the middleware reads the token from. | ## Client (Nuxt) `useLukkConfirmation` is the client for lukk's server-side step-up: the user re-proves their identity, and for a short window afterwards the sensitive routes open up. ### `useLukkConfirmation` ```ts const { confirmed, // ComputedRef — is a confirmation currently held? required, // Ref — a withConfirmation() action is waiting on your modal token, // Ref confirmPassword, // (password) => Promise withConfirmation, // (action: () => Promise) => Promise — per-action modal flow cancel, // () => void — abort a pending withConfirmation clear, // () => void } = useLukkConfirmation() ``` ### How it works When you confirm, the token is **automatically attached** in the `X-Lukk-Confirmation` header on every subsequent request — so once `confirmed` is `true`, the gated actions simply work, until the window expires server-side (lukk's [`confirm.ttl`](#configuration)). You never thread the token through your own calls. Where the token lives depends on the [mode](/transport-modes): * **`direct`** — the token is stored in client state and the client attaches the header. * **`bff`** — the proxy strips the token from the response and **holds it server-side**, injecting the header itself. The browser only sees `confirmed` flip to `true`; it never holds the confirmation credential. ```mermaid sequenceDiagram participant App participant Lukk as lukk App->>Lukk: confirmPassword(password) Lukk-->>App: { confirmation_token } Note over App: token stored · confirmed = true App->>Lukk: sensitive action (token auto-attached) Lukk-->>App: 200 ``` You never thread the token through your own calls — earning it is the only step. ### Confirming with a password ```ts const { confirmPassword, confirmed } = useLukkConfirmation() await confirmPassword(currentPassword) // confirmed.value === true ``` A wrong password throws a typed [`LukkError`](/lukk-core#errors). Call `clear()` to drop the confirmation early (it also clears on [logout](/authentication#logout)). ### Confirming with a passkey A passkey can satisfy step-up too — see [Passkeys → Step-up with a passkey](/passkeys#step-up-with-a-passkey): ```ts const { confirm } = useLukkPasskeys() await confirm() // stores the confirmation token, same as confirmPassword ``` ### Gating: per-action or per-page These actions require a held confirmation; without one, lukk responds `423 Locked`: * [Managing 2FA](/two-factor-authentication#managing-2fa) — enable, confirm, disable, regenerate recovery codes * [Registering or removing a passkey](/passkeys#managing-passkeys) Two shapes, both supported. #### Per-action (a modal) Wrap the sensitive call in `withConfirmation()`. It runs the action and, on a `423`, opens your modal (`required`), waits for a fresh confirmation, and retries once: ```ts const { withConfirmation } = useLukkConfirmation() const api = useLukkFetch() async function deleteAccount() { await withConfirmation(() => api('/account', { method: 'DELETE' })) } ``` Bind a global step-up modal to `required` — the user confirms (password **or** passkey), and the pending action retries automatically: ```vue ``` `withConfirmation` drops a stale confirmation on the `423`, so it always re-prompts when the server genuinely requires one (rather than trusting an expired client flag). `cancel()` rejects the pending action. #### Per-page (a section) To gate a whole page/section instead, use the [`lukk-confirmed`](/authentication#route-middleware) route middleware — stack it after `lukk-auth`; it sends an unconfirmed user to `/confirm-password`: ```ts definePageMeta({ middleware: ['lukk-auth', 'lukk-confirmed'] }) ``` Confirmation is client-session state, so a hard reload re-confirms. The server's `lukk.confirm` (423) is the real enforcement either way — these client guards are UX. Next: **[useLukkFetch](/use-lukk-fetch)** --- --- url: /lukk-docs/use-lukk-fetch.md --- # useLukkFetch The [BFF proxy](/transport-modes#bff) authenticates the *transport*; you still need a *client* that sends the session correctly in every context. A plain `$fetch('/api/...')` works in the browser but **forwards no cookie during SSR** — so the same call, server-rendered, returns a silent `401`. `useLukkFetch()` gets this right in client, SSR, and server-route contexts. ```ts const api = useLukkFetch() const { data } = await useAsyncData('me', () => api('/me')) // SSR-authenticated const user = await api('/me') // typed via generics ``` ## What it does `useLukkFetch()` returns a typed [`ofetch`](https://github.com/unjs/ofetch) instance that: * forwards **only** the sealed session cookie on SSR (never `authorization` or `x-forwarded-*`); * always sends `Accept: application/json`; * uses `redirect: 'manual'` — an upstream `3xx` becomes an external navigation to its `Location` (trusted only as far as your own API is) rather than a silently-followed HTML response; * rejects with a typed [`LukkError`](/lukk-core#errors) (`{ message, status, errors }`), so a `422` bag is ready to bind to a form. In **direct** mode it also attaches the in-memory bearer and single-flights a `401` refresh-and-retry (sharing `$lukk`'s one refresh, so the rotating token is never replayed). > \[!WARNING] > In any server/SSR context, use `useLukkFetch()` (or Nuxt's `useFetch`) for authenticated calls — a bare `$fetch` sends no cookie server-side and 401s. > \[!NOTE] > **Credentials never leak cross-origin.** The session cookie and bearer are attached only to a **same-origin-as-baseURL** target; a cross-origin URL passed to `useLukkFetch` gets no credentials (and `credentials: 'same-origin'`). Also, browsers can't read a manual-redirect target — an upstream `3xx` is surfaced as a navigation on **SSR** but is opaque on the client (the call resolves without following it). ## Organizing a typed API `useLukkFetch()` is a typed `ofetch` instance, so group your endpoints however you like — e.g. thin resource modules. lukk owns the auth transport and the Laravel error shape; your endpoints and their types stay yours: ```ts // app/api/users.ts export const usersApi = () => { const api = useLukkFetch() return { me: () => api('/me'), update: (dto: UpdateUser) => api('/me', { method: 'PATCH', body: dto }), } } ``` > \[!NOTE] > **Clean JSON errors out of the box.** The [app proxy](/transport-modes#authenticating-your-own-api-in-bff) sets `Accept: application/json` on forwarded requests (`api.forceJson`, default `true`), so Laravel's `expectsJson()` is true and unauthenticated / validation failures render as `401`/`422` **JSON** — no `bootstrap/app.php` change needed. (Without it, Laravel's default `redirectGuestsTo(fn () => route('login'))` makes `Authenticate` eagerly resolve `route('login')` *inside the middleware* → a confusing 500; note that `shouldRenderJsonWhen` alone does **not** fix this — it runs after the middleware already threw.) Opt out with `api: { forceJson: false }` only if a route under `path` legitimately serves non-JSON — then you must handle it Laravel-side (`redirectGuestsTo(fn () => null)`, or stamp `Accept` yourself). > > The BFF proxy itself is mounted at the exported `LUKK_BFF_PREFIX` (`/api/_lukk`); keep your routes clear of it. > \[!NOTE] > **Uploads & downloads.** The proxy streams both request and response bodies and forwards `Content-Type`/`Content-Disposition`, so `multipart/form-data` uploads and file downloads work. `forceJson` only sets the request **`Accept`** (the *response* format) — independent of the upload body — so validation errors and protected downloads still render clean JSON. Most download endpoints ignore `Accept` and return the file regardless; if a route under `path` content-negotiates a non-JSON success on `Accept` (rare), set `forceJson: false` for the mount. (All proxied responses are `Cache-Control: no-store`, and `Content-Length` is dropped — chunked transfer, so a progress bar won't show the total.) ## Cookies & CSRF (who owns `Set-Cookie`) > \[!NOTE] > The proxy owns cookies: it **strips every upstream `Set-Cookie`** your Laravel API returns and re-emits **only** lukk's sealed session cookie (rotated on refresh). So an app-API response's own cookie — a locale, a feature flag, Laravel's `XSRF-TOKEN` or web-session cookie — does **not** reach the browser through the proxy. This is by design: lukk is stateless (bearer JWT), and forwarding upstream cookies would risk leaking or colliding with the sealed session. If a browser cookie is genuinely needed, set it from a Nuxt plugin/server route rather than a proxied app-API response, or keep those cookie-driven routes off the `${path}` mount — **or** opt specific cookies in with `api.forwardSetCookie` (below). For the same reason CSRF is enforced by **origin** (a `SameSite=Strict` session + an `Origin` check), not a token — so Laravel's token-based CSRF (`419`) does not apply to lukk auth or the proxied API. In **direct** mode there is no proxy: the browser handles the app's `Set-Cookie` natively, subject to CORS and `SameSite`. > \[!NOTE] > **Opting cookies in — `api.forwardSetCookie`.** For a hybrid app whose Laravel API legitimately sets a browser cookie, pass an **allow-list of cookie names** to let just those through the proxy: > > ```ts > lukk: { mode: 'bff', api: { path: '/api', target: '…', forwardSetCookie: ['locale', 'theme'] } } > ``` > > Everything else is still stripped, and the sealed session cookie is **never** forwardable — even if you list its name, an upstream can't overwrite it. Default `[]` (strip everything). For a reactive form bound to Laravel validation over this same transport, see [`useLukkForm`](/use-lukk-form). Next: **[useLukkForm](/use-lukk-form)**. --- --- url: /lukk-docs/use-lukk-form.md --- # useLukkForm Most of what a Laravel app does is submit a form and show the validation errors it gets back. `useLukkForm` is the client for exactly that: hold the fields, submit them to your API, and bind a Laravel `422` bag onto per-field errors — over [`useLukkFetch`](/use-lukk-fetch), so it is authenticated, SSR-correct, and identical in either [transport mode](/transport-modes). It is modelled closely on Inertia's `useForm`, so it should feel familiar. ## `useLukkForm` ```ts const form = useLukkForm(initialData, options?) ``` `useLukkForm` returns a single reactive object. The **fields live under `form.data`** (not spread onto the form itself), so a field may safely be named `errors`, `processing`, or `submit`. Every `useLukkForm()` call is an independent form. ```ts const form = useLukkForm({ email: '', password: '' }) form.data // { email, password } — the live, editable fields form.errors // { email?: string, password?: string } — first message per field form.processing // boolean — a submit is in flight form.hasErrors // boolean form.isDirty // boolean — data differs from the defaults form.wasSuccessful // boolean — the last submit succeeded form.recentlySuccessful // boolean — true briefly after success (for a "Saved!" flash) ``` The optional second argument configures the form: ```ts useLukkForm({ … }, { recentlySuccessfulMs: 2000, // how long recentlySuccessful stays true rememberKey: 'signup', // persist form.data across SPA navigation (Nuxt useState) }) ``` With `rememberKey`, a half-filled form survives a route change and back — mount another form with the same key and its `data` is restored. (The reset/`isDirty` baseline isn't remembered; `isDirty` compares the restored data against the original `initial`.) Use it for **plain** drafts: the state is serialized into the SSR payload, so don't remember `File`/`Blob` fields or sensitive values. The value type flows through, so `form.data.email` is typed, and the returned form is a `LukkForm`. ## Basic Usage Bind the fields with `v-model="form.data.*"`, submit with a verb, and read `form.errors` in the template: ```vue ``` That is the whole loop — no manual error plumbing, no `Accept` headers, no bearer handling. ## Validation Errors When a submit fails with a Laravel `422`, lukk maps the bag onto `form.errors`, keyed by field. Laravel returns an **array** of messages per field; `form.errors` surfaces the **first** one (the same choice Inertia makes): ```jsonc // Laravel's 422 body { "message": "…", "errors": { "email": ["The email has already been taken."] } } ``` ```ts form.errors.email // "The email has already been taken." form.hasErrors // true ``` **Every submit clears the errors first**, then re-populates them only from a `422` — so a stale message never lingers into the next attempt. > \[!NOTE] > **Nested & array fields.** Laravel flattens the bag into **dot notation** — nested objects as `authorization.role`, array items as `users.0.email`. `form.errors` keys them exactly as returned, so read them with bracket access: `form.errors['authorization.role']`. For a nested `form.data`, use **`form.nestedErrors`** — the same errors expanded into a nested object: bind `form.nestedErrors.authorization?.role`, and array fields nest under their index, `form.nestedErrors.users?.[0]?.email`. You can also drive errors yourself (e.g. from a client-side check). All of these are **chainable** (they return the form): ```ts form.setError('email', 'That address looks off.') form.setError({ email: 'Required.', password: 'Too short.' }) // set several at once form.clearErrors('email') // clear one (or several) fields form.clearErrors() // clear all ``` ## Submitting There is a method per verb, plus a generic `submit`: ```ts form.post('/register') form.put(`/posts/${id}`) form.patch(`/users/${id}`) form.delete(`/posts/${id}`) form.get('/search') // sends the fields as the query string form.submit('post', '/register') // the generic form ``` For every verb except `get`, `form.data` is sent as the **request body**; `get` sends the (flat) fields as the **query string**. Each call **returns a promise of the parsed response body**, and **rejects with a typed [`LukkError`](/lukk-core#errors)** (`{ status, message, errors }`) on failure — so you can `await` the result, or `try/catch` and branch on `status`: ```ts const user = await form.post('/register') // typed via the generic try { await form.put(`/posts/${id}`) } catch (e) { if ((e as LukkError).status === 403) { /* … */ } } ``` The last argument accepts per-submit **[`ofetch` options](/use-lukk-fetch)** (headers, `signal`, …) alongside the lifecycle hooks below: ```ts form.post('/posts', { headers: { 'X-Idempotency-Key': key } }) ``` ## Lifecycle Hooks Pass `onSuccess`, `onError`, and `onFinish` in the submit options. They run in addition to the returned promise, and mirror Inertia's callbacks: ```ts form.post('/posts', { onSuccess: (post) => navigateTo(`/posts/${post.id}`), onError: (error) => console.warn(error.message), onFinish: () => { /* runs on success AND failure — like `finally` */ }, }) ``` * `onSuccess(result)` — the parsed response body. * `onError(lukkError)` — the typed error (also rethrown to your `await`/`catch`). * `onFinish()` — always runs, even if the request fails or `onSuccess` throws. ## Form State Beyond `processing` and `hasErrors`, the form tracks the outcome of the last submit — handy for buttons and "Saved!" flashes: ```vue Saved ✓ ``` * **`processing`** — a submit is in flight. Reset on every exit path (success or failure). * **`wasSuccessful`** — the last submit succeeded. Reset at the start of the next submit. * **`recentlySuccessful`** — flips to `true` on success and back to `false` after `recentlySuccessfulMs` (default 2000). * **`isDirty`** — whether `form.data` differs from the current defaults (see below). ## Defaults & Resetting A form remembers its **defaults** — initially the data you passed in. `reset` restores them, and `isDirty` compares against them: ```ts form.reset() // restore every field to its default form.reset('email') // restore only some fields form.resetAndClearErrors() // reset AND clear the errors in one call ``` You may re-baseline the defaults with `defaults` — for example after loading an edit form, so `isDirty` starts clean and `reset` returns to the loaded values: ```ts const form = useLukkForm({ title: '', body: '' }) const post = await $lukk('/posts/1') // load current values form.data.title = post.title form.data.body = post.body form.defaults() // baseline := current data → isDirty is false again form.defaults('title', 'Untitled') // re-baseline a single field form.defaults({ title: '…', body: '…' })// or several ``` **On a successful submit, the defaults are re-baselined to the just-submitted data automatically**, so `isDirty` flips back to `false` after a save. For a *create* form where you want the fields cleared instead, call `reset()` in `onSuccess`: ```ts form.post('/posts', { onSuccess: () => form.reset() }) // clear the form after creating ``` > \[!NOTE] > `isDirty` is a structural (JSON) comparison of `data` vs. the defaults, so it does not diff `File`/`Blob` fields (both serialize to `{}`) — track those separately if you need to. ## Transforming Data Register a `transform` to map `form.data` into the payload sent on **every subsequent submit** — without mutating the fields the user sees. A classic use is dropping a confirmation field: ```ts const form = useLukkForm({ password: '', password_confirmation: '' }) form.transform((data) => ({ password: data.password })) // send only `password` await form.post('/password') // body is { password: '…' } ``` ## File Uploads Put a `File` or `Blob` anywhere in `form.data` and the submit is **automatically sent as `multipart/form-data`** — nested keys are flattened Laravel-style (`avatar`, `tags[0]`, `meta[views]`), booleans become `'1'`/`'0'`, and `Date`s become ISO strings. The [BFF proxy streams the upload](/transport-modes#bff). ```vue ``` Force multipart even without a file with `forceFormData: true`. For a ``, store a `File[]` (spread the `FileList`: `[...files]`) rather than the raw `FileList`. > \[!NOTE] > Files must be sent by a **body** verb — a `File` in a `get` query is stringified and lost. `File`/`Blob` fields are passed **by reference** through `reset`/`isDirty`/the success rebase, so large uploads are never byte-copied. ## Cancelling a Submit `form.cancel()` aborts the most-recent in-flight submit; it then rejects with an `AbortError`: ```ts const form = useLukkForm({ q: '' }) async function search() { form.cancel() // drop any previous in-flight search try { results.value = await form.get('/search') } catch (e) { if ((e as Error).name !== 'AbortError') throw e } } ``` If you pass your own `signal` in the submit options, that wins and `cancel()` is a no-op for that submit. ## Notes & Caveats * **Transport-agnostic.** `useLukkForm` works identically in **BFF** and **direct** mode, and in an `useAsyncData`/SSR context, because it submits through [`useLukkFetch`](/use-lukk-fetch) — which forwards the session on SSR and injects the bearer as the mode requires. * **Field values must be plain.** Use strings, numbers, booleans, arrays, plain objects, `Date`, `File`/`Blob`. Don't pass functions, class instances, or reactive proxies as fields — `reset`/`isDirty` clone and compare them structurally. * **`get` flattens only top-level fields** into the query string. Nested objects don't round-trip as query parameters. * **Chaining.** `setError`, `clearErrors`, `reset`, `resetAndClearErrors`, `defaults`, and `transform` all return the form, so they compose: `form.reset().clearErrors()`. > \[!NOTE] > A custom `/register` (or bespoke login) route lives on the Laravel side. To hand back a lukk session after your own registration flow, issue tokens with [`startSession()`](/authentication#starting-sessions-manually); to accept a different credential field or add checks at login, use [custom login logic](/customization#custom-login-logic). Next: **[Using lukk-core](/lukk-core)**. --- --- url: /lukk-docs/lukk-core.md --- # Using lukk-core `lukk-core` is the framework-agnostic client that `lukk-nuxt` is built on. Use it directly when you're not on Nuxt — a React app, a Svelte app, a CLI, a different SSR framework — or when you're writing a new binding. ## Install ```bash npm i lukk-core ``` It has **no runtime dependencies** and ships ESM + types. ## Creating a Client `createLukkClient` takes a set of hooks — the seam where *you* decide where tokens live and how a refresh happens — and returns a typed client with a method per lukk endpoint: ```ts import { createLukkClient, isTwoFactorChallenge } from 'lukk-core' let accessToken: string | null = null const lukk = createLukkClient({ baseURL: 'https://api.example.com/auth', getAccessToken: () => accessToken, onTokens: pair => { accessToken = pair.access_token }, // Return null when not refreshable. (The client also treats a throwing // refresh as "not refreshable", but returning null is the documented contract.) refresh: () => lukk.refreshTokens().catch(() => null), // direct mode: relies on the __Host- cookie }) const result = await lukk.login({ email, password }) if (isTwoFactorChallenge(result)) { await lukk.twoFactorChallenge({ challenge_token: result.challenge_token, code }) } ``` The client attaches the bearer token, and on a `401` it calls `refresh` once and retries the original request — concurrent 401s share a **single** in-flight refresh. ## The Hooks | Hook | Type | Purpose | |---|---|---| | `baseURL` | `string` | lukk's auth URL incl. the route prefix. | | `fetch?` | `typeof fetch` | Custom fetch (defaults to the global). | | `getAccessToken?` | `() => string \| null \| Promise<…>` | The bearer token to attach. | | `getConfirmationToken?` | `() => string \| null \| Promise<…>` | The step-up token for `X-Lukk-Confirmation`. | | `refresh?` | `() => Promise` | Obtain a fresh pair on a 401; `null` if not refreshable. | | `onTokens?` | `(pair) => void` | Persist a freshly-minted pair (login / refresh / passkey login / restore). | | `onUnauthenticated?` | `() => void` | Refresh failed — the session is gone. | | `confirmationHeader?` | `string` | Header name (default `X-Lukk-Confirmation`). | Where the tokens actually live — memory, a sealed cookie, a server session — is entirely the caller's choice. The core only knows how to *speak* to lukk. ## Methods ```ts // session lukk.login(credentials) // → TokenPair | TwoFactorChallenge lukk.twoFactorChallenge({ challenge_token, code | recovery_code }) lukk.refreshTokens(refreshToken?) // direct mode passes the token; cookie mode omits it lukk.restore() // silent refresh; null when there's no session lukk.logout() lukk.revokeAllSessions() lukk.revokeOtherSessions() // step-up confirmation lukk.confirmPassword(password) // → { confirmation_token } lukk.confirmPasskey(ceremonyId, credential) // two-factor management lukk.enableTwoFactor() // → { otpauth_uri, recovery_codes } lukk.confirmTwoFactor(code) lukk.disableTwoFactor() lukk.recoveryCodeCount() // → { remaining, total } lukk.regenerateRecoveryCodes() // passkeys lukk.passkeyRegistrationOptions() lukk.registerPasskey(credential, name?) lukk.passkeyLoginOptions() // → { ceremony_id, options } lukk.loginWithPasskey(ceremonyId, credential) lukk.listPasskeys() // → { passkeys: PasskeySummary[] } lukk.deletePasskey(credentialId) ``` Every shape is exported as a type (`TokenPair`, `TwoFactorChallenge`, `PasskeyLoginOptions`, …) and [conformance-tested](/architecture#conformance) against real lukk. ## Errors A failed request throws a typed `LukkError`: ```ts interface LukkError { status: number message: string errors?: Record // Laravel validation errors (422) } ``` ```ts try { await lukk.login({ email, password }) } catch (e) { const err = e as LukkError if (err.status === 422) showFieldErrors(err.errors) } ``` ## WebAuthn Helpers For passkey ceremonies, the core exports the `ArrayBuffer ⇄ base64url` plumbing lukk speaks: ```ts import { toCreationOptions, // lukk JSON → navigator.credentials.create() input toRequestOptions, // lukk JSON → navigator.credentials.get() input credentialToJSON, // a PublicKeyCredential → JSON to post back to lukk } from 'lukk-core' const options = toCreationOptions(await lukk.passkeyRegistrationOptions()) const credential = await navigator.credentials.create({ publicKey: options }) await lukk.registerPasskey(credentialToJSON(credential as PublicKeyCredential)) ``` `lukk-nuxt`'s `useLukkPasskeys` wraps exactly this — you only need these helpers when writing your own binding. Next: **[Deployment](/deployment)**. --- --- url: /lukk-docs/deployment.md --- # Deployment lukk is built for a single first-party service that issues *and* verifies its own tokens. That's the default, and most apps never need anything else. But you can also split login off from your API, mint one token for several services, or hand a public key to an independent verifier. This page covers those topologies and the operational requirements they all share. Everything here is server-side. The client's production concerns — the BFF session secret and throttling behind a proxy — are at the [bottom of this page](#client-nuxt-in-production). ## Server (Laravel) ### Single service (default) One Laravel app mints tokens at `/auth/login` and verifies them on every protected request. Issuer and audience are the same thing — your API — so they share a value: ```dotenv LUKK_ISSUER=https://api.example.com LUKK_AUDIENCE=https://api.example.com ``` Nothing else to configure. A browser SPA or a BFF in front of this API is **not** a verifier — it only holds and forwards the token — so it needs no lukk configuration of its own. #### Same origin (front-end + API on one domain) The simplest — and most common — setup: one domain (say `example.com`) serves the front-end **and** lukk's `/auth/*` and protected routes. Issuer and audience are just that domain: ```dotenv LUKK_ISSUER=https://example.com LUKK_AUDIENCE=https://example.com LUKK_COOKIE_MODE=true ``` This is *less* work than the split sub-domain case, not more: * **Cookie mode fits a direct browser SPA.** The refresh token rides in the `__Host-refresh` cookie (sent automatically on every same-origin request); the access token stays in memory and goes out as a `Bearer` header. (If you have a server-rendered/BFF layer, leave `cookie_mode` off and use [BFF mode](/transport-modes#bff) instead.) * **No CORS.** Same origin means no preflight and no credentialed-CORS config — the cross-origin operational note below simply doesn't apply. * **CSRF is covered by design.** The refresh cookie is `SameSite=Strict` (a cross-site page can't trigger a refresh with it) and protected routes authenticate via the `Bearer` header, not an ambient cookie. * **Passkeys are the easy case:** `rp_id = example.com`, `origins = ["https://example.com"]` — front-end and API share the exact origin. lukk's own `/auth/*` routes always render JSON errors (it forces JSON on them); if the same app also serves web routes, your *own* `auth:api` routes need the same — see [Installation](/installation#wire-the-guard). ### Splitting auth and API You can run a dedicated **auth service** (login, refresh, logout, 2FA, passkeys) and one or more **API services** that only verify tokens. This works today, with no code changes, as long as the services trust each other — see the [caveat](#a-note-on-trust) below. A browser client calling these services across origins uses the lukk-js [direct transport mode](/transport-modes#direct). **On the auth service** — the default setup. It keeps the routes, the database, and the refresh-token rotation. **On each API service** — install lukk and configure it as verify-only: ```php // config/lukk.php 'routes' => false, // this service doesn't expose login/refresh/logout ``` ```dotenv LUKK_SECRET=… # the SAME secret as the auth service LUKK_ISSUER=https://api.example.com LUKK_AUDIENCE=https://api.example.com ``` Then map the guard ([Installation](/installation#wire-the-guard)) and protect routes with `auth:api` as usual. An API service needs **no database** — no `refresh_tokens`, 2FA, or passkey tables — because it never mints or rotates anything. It only verifies the access token and resolves the user. > \[!IMPORTANT] > Point every service at a **shared denylist cache** (`denylist_store` → a Redis all of them reach). That's how a logout or a reuse-revocation on the auth service immediately stops tokens on the API services. Without it, a revoked token still works on an API service until it expires — bounded by `access_ttl` (15 minutes by default), but not instant. > \[!WARNING] > **A note on trust.** With the default **HS256** algorithm, verifying a token requires the **same secret** that signs it — so every verifying service holds the signing key and could, in principle, mint tokens too. That's fine when the services are all yours and equally trusted. If a verifier must *not* be able to mint (a different trust domain, or least-privilege isolation), use [asymmetric keys](#asymmetric-keys) instead. ### Multiple services (audiences) The `aud` claim says who a token is *for*. `LUKK_AUDIENCE` is comma-separated, so the **auth service** that mints the token lists every service it's for: ```dotenv # on the auth service — mint one token for both LUKK_AUDIENCE=https://api.example.com,https://billing.example.com ``` Each service sets **its own** audience and accepts the token when it's in the list: ```dotenv # on the billing service LUKK_AUDIENCE=https://billing.example.com ``` So a service's `LUKK_AUDIENCE` is exactly the set of audiences it accepts. A service whose audience isn't listed rejects the token — this is what stops a token meant for one service from being replayed against another. A single-audience token (the default) stays a plain string; only multi-audience tokens become an array. ### Asymmetric keys (RS256 / ES256) {#asymmetric-keys} When a verifier must be able to *check* tokens but never *mint* them — a separate trust domain, or least-privilege isolation — a shared symmetric secret is the wrong tool. You want the verifier to hold only a **public** key, which means **RS256/ES256 + a JWKS endpoint**. This is built in. Generate a signing keypair on the auth service: ```bash php artisan lukk:keygen ``` Set the algorithm and point lukk at the keypair: ```dotenv LUKK_ALGORITHM=RS256 # or ES256 ``` The auth service now exposes `GET /auth/jwks` — a cacheable JWK Set of **public keys only**. Each verify-only API runs the same algorithm with just the **public** key in its `keys.public` and no private key, so it can validate tokens but never sign them: ```dotenv # on each verify-only API service LUKK_ALGORITHM=RS256 # match the auth service # keys.public holds the public key only — no private key ``` A lukk verifier reads that public key from its own config; it does **not** fetch a remote JWKS. The `/auth/jwks` endpoint is where you *publish* the key — copy it into the verify-only service's config, or point a non-lukk consumer (an API gateway, another framework) at the URL. Keys are addressed by `kid`, so you can rotate the signing key without forcing logouts: publish the new key alongside the old, migrate signing, then retire the old `kid` once its tokens have expired. The alg is pinned from config and is **never** read from the token header — the alg-confusion defense. See [Configuration](/configuration) for the key settings and [Architecture](/architecture) for the design rationale. ```mermaid flowchart LR U[Browser / Client] U -->|login · refresh| AUTH[Auth service
lukk · RS256 private key
mints + signs tokens] AUTH --> JWKS[GET /auth/jwks
public keys only] U -->|Bearer access token| API1[API service
lukk · public key only
verifies, never mints] U -->|Bearer access token| API2[API service
public key only] JWKS -. publish / copy key into config .-> API1 JWKS -. publish / copy key into config .-> API2 ``` ### Pruning expired tokens Expired and revoked refresh-token rows accumulate over time. The `lukk:prune` command deletes them: ```bash php artisan lukk:prune ``` The package schedules this command to run **daily by default**. To take over scheduling, opt out from a service provider's `boot` method and register your own cadence: ```php use Illuminate\Support\Facades\Schedule; use Lukk\Lukk; public function boot(): void { Lukk::disableScheduling(); Schedule::command('lukk:prune')->hourly(); } ``` Only the auth service (the one holding the `refresh_tokens` table) prunes; verify-only API services have no database to prune. ### Operational requirements A few environment concerns that the package can't enforce for you but depends on: * **HTTPS everywhere.** Access tokens are bearer credentials and refresh cookies are `Secure` — serve every issuing and verifying route over TLS. The hardened `__Host-` cookies lukk sets are only accepted by browsers over HTTPS. (Running plain HTTP locally has its own caveats — see [Local Development](/local-development).) * **Trusted proxies.** Every rate limit (login, refresh, passkeys, 2FA) keys on `$request->ip()`. Behind a load balancer or reverse proxy you **must** configure Laravel's `TrustProxies` to your actual proxy IPs. If it trusts arbitrary clients, an attacker spoofs `X-Forwarded-For` to mint a fresh throttle bucket per request and defeats all rate limiting. * **Throttling behind a BFF.** A back-end-for-frontend (e.g. `lukk-nuxt` in BFF mode) sends *every* user's auth traffic from the BFF server's single IP, so the per-IP login/refresh/2FA/passkey limits collapse onto one bucket for your whole user base. Either raise those limits substantially for a BFF deployment, or have the BFF forward `X-Forwarded-For` (with `TrustProxies` set) so lukk throttles per real client. * **Shared revocation store.** The denylist, the TOTP replay cache, and the passkey/2FA throttles all live in the cache (`denylist_store`). Across multiple nodes this **must** be a shared, persistent store (e.g. Redis) — never the `array` driver and never a per-node cache, or a revoked token can still be accepted on another node and replay protection isn't authoritative. * **CSRF in cookie mode.** The refresh cookie is `SameSite=Strict` (set by lukk). If your front-end is on a different site from the API, also ensure CORS is locked to the exact front-end origin with credentials — never `Access-Control-Allow-Origin: *` with `supports_credentials`. * **JSON error responses.** lukk's `/auth/*` routes return `401`/`422` JSON on their own (it forces `Accept: application/json`), immune to your exception config. Your *own* `auth:api` routes are not — attach the `lukk.force-json` middleware (`Route::middleware(['lukk.force-json', 'auth:api'])`) or see [Installation](/installation#wire-the-guard) for the alternatives (don't rely on `shouldRenderJsonWhen` alone; it doesn't cover the guest redirect). Run through the full [security checklist](/security) before you ship. ## Client (Nuxt) in production The client is mostly configuration you set once ([Installation](/installation), [Configuration](/configuration)); two things matter specifically at deploy time, both in **BFF mode**: * **The session secret must be identical across every instance.** `NUXT_LUKK_SESSION_PASSWORD` is the confidentiality boundary for the sealed session cookie — the BFF equivalent of `APP_KEY`. A load-balanced deploy with per-instance secrets silently invalidates sessions, and rotating it logs everyone out. See [Configuration → the session password](/configuration). * **Throttling collapses onto the BFF's IP.** Every user's auth traffic egresses from the BFF server, so lukk's per-IP throttles see one address — raise the limits or forward `X-Forwarded-For` (this mirrors the server note above). Keep lukk's `grace_seconds > 0` so the proxy's single-flight refresh never trips a false family revocation. In `direct` mode there's nothing server-side to run, so neither concern applies — it's the only option for a fully static (SSG) deploy. See [Transport Modes](/transport-modes) for the trade-off, and [Local Development](/local-development) for the HTTPS-cookie caveat when running the client locally. Next: **[Local Development](/local-development)** --- --- url: /lukk-docs/local-development.md --- # Local Development lukk's session cookies are hardened for production: the BFF **sealed session** and the direct-mode **refresh token** both ride a `Secure`, `__Host-`-prefixed cookie. That's exactly what you want in production — but it has one consequence in local dev: **a browser will not persist a `Secure` cookie over plain `http`, even on `localhost`.** So without handling it, you'd log in over `http://localhost:3000` and a page reload would appear logged-out, with no error. There's nothing to debug — it's the cookie being dropped. How you relax it depends on which [transport mode](/transport-modes) you run. ## BFF mode — automatic (client) `lukk-nuxt` relaxes the sealed session cookie **for you**, decided once at build time: | How you run it | Session cookie | | --- | --- | | `nuxi dev` (http) | `lukk-session`, **not** `Secure` — persists over `http://localhost` | | `nuxi dev --https` | `__Host-lukk-session` + `Secure` — mirrors production | | `nuxi build` / `nuxi preview` (production) | `__Host-lukk-session` + `Secure`, always | You don't configure anything. The decision is made at build from `nuxt.options.dev` and your dev-server https setting — **never from the request at runtime** (so there's no `x-forwarded-proto` to spoof), and the relaxed cookie is never part of a production build. * To **exercise the exact production cookie** locally, use `nuxi dev --https` (Nuxt generates a dev cert) or `nuxi preview` (a real production build). * For an unusual setup — e.g. `nuxi dev` (http) sitting behind your own TLS-terminating proxy — force it explicitly: ```ts // nuxt.config.ts lukk: { session: { cookieSecure: true } } // or false; default = secure in prod + dev-https ``` > \[!WARNING] > Never set `cookieSecure: false` in a production build. ## Direct mode — one env flag on the lukk API (server) In direct-cookie mode the refresh token lives in a `__Host-refresh` cookie set by the **lukk PHP API**, which can't know your front-end is a dev build. So for local dev over http, relax it on the lukk side: ```dotenv # lukk (.env) — LOCAL DEV ONLY. Never in production. LUKK_COOKIE_SECURE=false ``` `cookie.secure` (env `LUKK_COOKIE_SECURE`, default `true`) controls the refresh cookie's `Secure` attribute. When you set it `false`, lukk drops `Secure` **and** the `__Host-` prefix from the cookie name (the prefix requires `Secure`, so the browser would otherwise reject it) — the set, clear, and read sides all stay in sync. Leave it `true` (the default) everywhere else. See the [configuration reference](/configuration#cookie). ## Why not just always relax it? Because the `Secure` cookie is the actual protection — the refresh/session token must never travel over plain http where it can be intercepted. Both switches above are **development-only and default to secure**; the BFF one additionally can't reach a production bundle at all. The right fix for a real deployment is HTTPS, not a relaxed cookie — see [Deployment](/deployment). Next: **[Security](/security)**. --- --- url: /lukk-docs/security.md --- # 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](/architecture); for the token internals, [Tokens & Rotation](/tokens-and-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, 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. * **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. * **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 one `access_ttl`. It dispatches [`RefreshTokenReused`](/events) for 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_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](/tokens-and-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.** 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, private` so 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 whose `sub` user was deleted — are rejected at the guard. ## Transport hardening (client) Where the tokens physically live is a [transport-mode](/transport-modes) choice, and each mode has its own containment. ### BFF mode — nothing in the browser * **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 upstream `Set-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=/`, no `Domain` — the `__Host-` prefix the browser enforces), **and** the proxy rejects any state-changing request whose `Origin` doesn'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-spoofable `X-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](https://httpwg.org/specs/rfc6265bis.html#section-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`](/customization); 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. ### Direct mode — hardened cookie, in-memory access * 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-refresh` cookie (`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-`baseURL` target, never to an absolute cross-origin URL, and uses `credentials: '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**. ### 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](/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. ## Standards mapping | 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 | ## 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 TTL ≤ 15 min; 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 refresh tokens return `401`, not 500, without leaking the reason. * \[x] Rotation on; post-grace replay revokes the whole family. * \[x] Grace window prevents false logout under concurrency. * \[x] Denylist (`fid`/`jti`) kills access within one request; global logout (`DELETE /auth/sessions`) works. * \[x] Login throttled; password check constant-time; unknown user indistinguishable from wrong password. * \[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`; proxy `Origin`-checks state-changing requests; upstream `Set-Cookie` and `X-Forwarded-*` stripped; app-API proxy has a fixed SSRF-safe target. * \[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`. * \[x] **(Passkeys)** Challenge server-generated, single-use, origin/RP-ID bound; assertion checks UP/UV + signature + pinned algorithms; sign-count regression rejected but `0` never flagged; credential IDs globally unique; public key encrypted at rest; `amr` reflects `webauthn`. Next: **[Architecture](/architecture)** --- --- url: /lukk-docs/architecture.md --- # Architecture This is the code-level reference: how lukk and lukk-js are put together, why, and where the swap seams are. For the security invariants and the audit checklist see [Security](/security); for the token internals, [Tokens & Rotation](/tokens-and-rotation). ## Why a custom package lukk deliberately does not use Passport, `league/oauth2-server`, or `tymon/jwt-auth`. OAuth's authorization-code / PKCE / confidential-client machinery exists to delegate access to **third parties** — client IDs, redirect URIs, an authorization server. A **first-party** application, where you own both the client and the API, has no third party to delegate to, so all of that is ceremony with no payoff. lukk replaces it with a direct credential login that mints its own tokens, and keeps only the OAuth-world patterns that genuinely carry weight: **short-lived access JWTs, opaque rotating refresh tokens, reuse detection, and a denylist.** The one firm rule: the JWS layer is delegated to the audited `firebase/php-jwt`. Hand-rolling JWT encode/verify is exactly where `alg=none`, RS256→HS256 confusion, and non-constant-time comparisons creep in — lukk's code touches **lifecycle and policy**, never the crypto primitives. ## Server (Laravel) ### Layered design The architecture mirrors Sanctum: | Layer | Responsibility | |---|---| | **Controllers** | Thin — run an Action, return a Response contract. | | **Actions** | Single-purpose orchestration; the policy lives here. | | **Contracts** | The swap seams — issuer, verifier, repository, denylist, responses. | | **Concrete implementations** | The defaults bound to each contract. | This separation is what makes lukk customizable without edits (see [Customization](/customization)). Critically, the rotation **policy** (in `Actions\RotateRefreshToken`) is separate from token **storage** (behind `Contracts\RefreshTokenRepository`), so you can swap the database for Redis without touching the security-critical logic. Controllers are resource-oriented — one resource (or single-action `__invoke`) controller per concern, using resourceful verbs (`store`/`destroy`/`index`), never one god-controller — and stay thin: run an Action, return a Response contract. ### The customization hub Customization is Sanctum-style, through three coordinated mechanisms: * the static **`Lukk`** hub with closure hooks — `authenticateUsing`, `tokenClaimsUsing`, `useRefreshTokenModel`, `actingAs`, `disableScheduling`; * **feature flags** in `config('lukk.features')` — 2FA and passkeys are opt-in and never autoloaded unless enabled; * **rebindable contracts** — bind your own implementation of any swap seam in a service provider. If you've used `Sanctum::usePersonalAccessTokenModel()`, you already know how to customize lukk. See [Customization](/customization). ### Where the concrete code lives The default implementations bound to each contract live under `Tokens/`, `Refresh/`, `TwoFactor/`, `Passkeys/`, `Support/`, and `Http/Responses/`. The signing/verification material is resolved by `Tokens\Jwt\KeyRing`, with the algorithm pinned from config (never read from the token header). Responses implement Laravel's `Responsable` contract so you can reshape the output. ## Client (Nuxt) ### Two layers lukk-js is a framework-agnostic core with per-framework bindings on top: ```mermaid flowchart TB Nuxt[lukk-nuxt
composables · middleware · BFF proxy] --> Core[lukk-core
contract types · client · WebAuthn helpers] Core --> Lukk[lukk HTTP API] Future[lukk-react · …] -.-> Core ``` `lukk-core` knows the lukk contract and how to *speak* it. It knows nothing about storage, reactivity, or any framework. `lukk-nuxt` supplies those — Nuxt `useState`, route middleware, a Nitro proxy — by wiring the core's hooks. A future `lukk-react` would do the same with React state, reusing the entire core unchanged. This is why the core has zero runtime dependencies: it's pure contract plus plumbing. ### The hooks seam `createLukkClient(hooks)` is the seam. The client never decides *where* a token lives or *how* a refresh happens — it asks, through hooks: * `getAccessToken` / `onTokens` — read and persist the access token. * `refresh` — produce a fresh pair on demand. * `getConfirmationToken` — supply the step-up token to auto-attach. A binding fills these in for its world. In `lukk-nuxt`'s direct mode they read/write SSR-safe `useState`; in BFF mode the Nitro proxy fills the same role server-side. Same core, different seam wiring. See [Using lukk-core](/lukk-core). ### One API, two transports The composables expose one surface; a config switch chooses the [transport](/transport-modes) beneath it: * **`direct`** — the client plugin points the core's `baseURL` at lukk and stores the access token in memory. * **`bff`** — the client points at the same-origin proxy (`/api/_lukk`), and a Nitro handler captures the tokens into a sealed cookie, refreshing server-side on a 401. The proxy is the only mode-specific code; the composables don't know which mode they're in. That's what lets you change `mode` without touching a component. ### Refresh & retry When a request returns `401`, the client calls `refresh` once and retries the original request with the new token. Concurrent 401s — common under SSR or a burst of parallel requests — are collapsed into a **single in-flight refresh** (`singleFlight`), so a page that fires ten requests at once triggers one refresh, not ten. The BFF proxy single-flights its server-side refresh per session for the same reason. Both dovetail with lukk's [grace window](/tokens-and-rotation#the-grace-window), which tolerates concurrent refreshes without treating them as token reuse. ## Mapping the two halves Every lukk-js concept has a lukk counterpart: | lukk-js | lukk | |---|---| | `direct` mode | `cookie_mode => true` | | `bff` mode | `cookie_mode => false` (body mode) | | `useLukkConfirmation` | the `lukk.confirm` step-up middleware | | `X-Lukk-Confirmation` auto-attach | `confirm.header` | | single-flight refresh | the rotation grace window | | `useLukkPasskeys` / `useLukkTwoFactor` | the `passkeys` / `two_factor` features | ## Conformance A hand-written TypeScript type is a guess about the server until something checks it. lukk-js checks it: a [conformance harness](https://github.com/stsepelin/lukk-js/tree/main/conformance) boots a **real lukk instance** (a Laravel app installing the actual package) and runs the client's flows against it, in CI, across **both** delivery modes. It doesn't stop at shapes — it completes the real ceremonies: a genuine TOTP code clears a 2FA challenge, and a software WebAuthn authenticator runs a full passkey **register → passwordless login**. If lukk changes a response and lukk-js doesn't, conformance goes red. That's the guarantee the contract types are worth trusting — lukk is the source of truth, and conformance proves lukk-js matches it. ## Quality gate Both packages enforce **100% test coverage** (statements, branches, functions, lines) — the same bar on both sides. Lint and type-checking run in CI alongside the coverage gate and the conformance suite. The Nuxt runtime is unit-tested through a lightweight `#imports` mock, so the tests run without booting Nuxt; the PHP suite runs on Testbench against an in-memory sqlite database. ## References **IETF RFCs** * [RFC 7519 — JWT](https://www.rfc-editor.org/rfc/rfc7519) * [RFC 7515 — JWS](https://www.rfc-editor.org/rfc/rfc7515) * [RFC 7517 — JWK / JWKS](https://www.rfc-editor.org/rfc/rfc7517) * [RFC 7518 — JWA](https://www.rfc-editor.org/rfc/rfc7518) * [RFC 6749 — OAuth 2.0](https://www.rfc-editor.org/rfc/rfc6749) · [RFC 6750 — Bearer Token Usage](https://www.rfc-editor.org/rfc/rfc6750) * [RFC 8725 — JWT Best Current Practices](https://www.rfc-editor.org/rfc/rfc8725) * [RFC 9068 — JWT Profile for OAuth Access Tokens (`at+jwt`)](https://www.rfc-editor.org/rfc/rfc9068) * [RFC 9700 — OAuth 2.0 Security BCP](https://www.rfc-editor.org/rfc/rfc9700) * [OAuth 2.1 (draft)](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/) · [OAuth 2.0 for Browser-Based Apps (draft)](https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/) **OWASP** * [Application Security Verification Standard (ASVS)](https://owasp.org/www-project-application-security-verification-standard/) * [JWT Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html) · [Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) **Two-factor & passkeys** * [RFC 6238 — TOTP](https://www.rfc-editor.org/rfc/rfc6238) · [RFC 4226 — HOTP](https://www.rfc-editor.org/rfc/rfc4226) · [RFC 8176 — Authentication Method Reference (`amr`)](https://www.rfc-editor.org/rfc/rfc8176) * [W3C WebAuthn Level 2](https://www.w3.org/TR/webauthn-2/) · [NIST SP 800-63B](https://pages.nist.gov/800-63-3/sp800-63b.html) **Libraries** * [`firebase/php-jwt`](https://github.com/firebase/php-jwt) · [`pragmarx/google2fa`](https://github.com/antonioribeiro/google2fa) · [`web-auth/webauthn-lib`](https://github.com/web-auth/webauthn-framework) Next: **[Events](/events)** --- --- url: /lukk-docs/events.md --- # Events lukk dispatches a small set of Laravel events at security-relevant moments. Attach listeners to log, alert, or react — you never edit the package. This page is server-only. ## Security events ### RefreshTokenReused When a refresh token that should no longer be usable is presented, lukk force-revokes the entire token family and dispatches `Lukk\Events\RefreshTokenReused`. This is a token-theft signal — listen for it to log or alert: ```php use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Lukk\Events\RefreshTokenReused; Event::listen(function (RefreshTokenReused $event) { Log::warning('Refresh token reuse detected', [ 'family' => $event->familyId, 'reason' => $event->reason, ]); }); ``` The event carries two readonly properties, `$familyId` and `$reason`. The `reason` is one of: | Reason | Meaning | |---|---| | `reuse` | A consumed token was replayed after the grace window — a successor already exists. The textbook theft signal. | | `revoked` | An already-revoked token was replayed. | > \[!IMPORTANT] > The revoke-then-dispatch happens **after** the rotation transaction commits, so the family revocation and the event stay consistent. See [Tokens & Rotation](/tokens-and-rotation) for the reuse-detection mechanics and the grace window that keeps normal concurrency from tripping a false revoke. ### PasskeyCloneDetected When [passkeys](/passkeys) are enabled, an assertion whose signature counter *regresses* dispatches `Lukk\Events\PasskeyCloneDetected` — a signal that the authenticator may have been cloned. It's the credential-layer analog of refresh-token family reuse detection; listen to alert and consider disabling the credential: ```php use Illuminate\Support\Facades\Event; use Lukk\Events\PasskeyCloneDetected; Event::listen(function (PasskeyCloneDetected $event) { Log::warning('Possible passkey clone', [ 'user' => $event->userId, 'credential' => $event->credentialId, ]); }); ``` The event carries `$userId` and `$credentialId`. A **zero** counter is never flagged — synced passkeys always report `0`. ## Framework events lukk also dispatches standard Laravel auth events, so your existing listeners work unchanged: | Event | When | |---|---| | `Illuminate\Auth\Events\Lockout` | The login throttle trips — an IP has exceeded the failed-login rate limit. Listen to alert on brute-force attempts. | | `Illuminate\Auth\Events\Verified` | A user completes [email verification](/email-verification). | | `Illuminate\Auth\Events\PasswordReset` | A user completes a [password reset](/password-reset). | Login is constant-time by design (an unknown email runs the same hashing work as a wrong password), and every token-bearing response is sent `Cache-Control: no-store` — both are part of the security contract covered in [Security](/security). Next: **[Customization](/customization)** --- --- url: /lukk-docs/customization.md --- # Customization lukk follows the Sanctum pattern: every moving part is either a **contract bound to a default** (rebind it in a service provider) or a **closure hook** on the static `Lukk` class (register it from a service provider's `boot` method). You never edit the package. This page is server-focused. ## The Lukk hub The `Lukk` class is a static configuration hub, like `Sanctum`. Register hooks from the `boot` method of a service provider (for example `App\Providers\AppServiceProvider`): ```php use Lukk\Lukk; public function boot(): void { Lukk::authenticateUsing(/* ... */); Lukk::tokenClaimsUsing(/* ... */); Lukk::useRefreshTokenModel(/* ... */); } ``` `Lukk::actingAs()` (for authenticating a user in your own tests) and `Lukk::disableScheduling()` (to take over the `lukk:prune` cadence — see [Deployment](/deployment)) live on the same hub. ## Custom login logic By default lukk validates the `email` and `password` against your configured user provider. To take full control — extra conditions, a different credential field, a "must be active" check — pass a closure to `authenticateUsing`. Return the authenticated user, or `null` to reject: ```php use Illuminate\Http\Request; use Lukk\Lukk; Lukk::authenticateUsing(function (Request $request) { $user = User::where('email', $request->input('email'))->first(); if ($user && Hash::check($request->input('password'), $user->password) && $user->is_active) { return $user; } return null; }); ``` The login **throttle** still wraps your closure — failed attempts are rate-limited exactly as on the default path. **Constant-time** behaviour, however, becomes *your* responsibility: the package's unknown-user timing equalizer only runs on the built-in email/password path, so a closure that does `User::where(...)->first()` and hashes only when the user exists leaks a user-enumeration timing oracle. Make your closure take the same time whether or not the account exists — e.g. always run a `Hash::check` against a dummy hash when no user is found. ## Custom token claims Add custom claims — roles, a tenant id, anything your API needs — to every access token. The closure receives the user id and returns an array of claims: ```php use Lukk\Lukk; Lukk::tokenClaimsUsing(fn ($userId) => [ 'roles' => User::find($userId)->roles->pluck('name'), ]); ``` > \[!NOTE] > Your claims are merged in, but the standard claims (`sub`, `exp`, `iss`, `aud`, `jti`, `fid`, …) always win and cannot be overridden. ## Swapping the refresh token model To use your own Eloquent model for refresh tokens (to add columns, relationships, or scopes), extend the base model and register it — the Sanctum approach: ```php use Lukk\Lukk; use App\Models\RefreshToken; Lukk::useRefreshTokenModel(RefreshToken::class); ``` ## Swapping storage Refresh-token **storage** sits behind `Contracts\RefreshTokenRepository`, separate from the rotation **policy** (which lives in `Actions\RotateRefreshToken`). To move storage from the database to Redis, bind your own implementation — the policy is untouched: ```php use Lukk\Contracts\RefreshTokenRepository; use App\Auth\RedisRefreshTokenRepository; $this->app->bind(RefreshTokenRepository::class, RedisRefreshTokenRepository::class); ``` ## Reshaping responses The login, refresh, and logout responses are `Responsable` contracts. Rebind one to change the body shape, add headers, or switch between JSON and cookies: ```php use Lukk\Contracts\LoginResponse; use App\Http\Responses\MyLoginResponse; $this->app->bind(LoginResponse::class, MyLoginResponse::class); ``` The response contracts are `LoginResponse`, `RefreshResponse`, `LogoutResponse`, and `TwoFactorChallengeResponse`. > \[!NOTE] > The default response shape is the contract the lukk-js clients consume. If you reshape it, keep the client in sync (or adapt it) so the two don't drift — see [Authentication](/authentication) and [Using lukk-core](/lukk-core). ## Swapping the issuer, verifier, or denylist The cryptographic and revocation seams are contracts too. Rebind `Contracts\TokenIssuer` or `Contracts\TokenVerifier` to change how tokens are minted or validated (for example to move to RS256 — though that's built in; see [Deployment → Asymmetric keys](/deployment#asymmetric-keys)), or `Contracts\Denylist` to back revocation with something other than the cache. ## Available contracts | Contract | Default | Responsibility | |---|---|---| | `TokenIssuer` | `FirebaseTokenIssuer` | Mints access tokens. | | `TokenVerifier` | `FirebaseTokenVerifier` | Verifies access tokens and checks the denylist. | | `RefreshTokenRepository` | `DatabaseRefreshTokenRepository` | Persists refresh tokens and families. | | `Denylist` | `CacheDenylist` | Records and checks revoked `jti`/`fid` values. | | `LoginResponse` / `RefreshResponse` / `LogoutResponse` | built-in | Shape the HTTP responses. | | `TwoFactorChallengeResponse` | built-in | Shapes the 2FA login challenge. | | `TwoFactorProvider` | `Google2FaTotpProvider` | Generates and verifies TOTP codes. | | `WebAuthnCeremony` | `SpomkyWebAuthnCeremony` | Performs WebAuthn registration/assertion. | | `PasskeyRepository` | `DatabasePasskeyRepository` | Persists passkey credentials. | That's the whole customization surface. For the design rationale behind these seams, see [Architecture](/architecture); for the events lukk fires at the security-relevant moments, see [Events](/events). Questions or contributions are welcome on the [lukk](https://github.com/stsepelin/lukk) and [lukk-js](https://github.com/stsepelin/lukk-js) repositories.