Skip to content

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 earned by the session presenting it. If it is missing, expired, or belongs to another session, the route returns 423 Locked.

The window length is confirm.ttl (default 5 minutes). lukk's own two-factor and passkey management routes, and account erasure and export, are protected this way.

Bound to the session that earned it ​

A confirmation carries the identity of the refresh-token family that re-verified the credential, and lukk.confirm refuses one presented by a different session:

json
// 423 Locked
{
  "message": "This confirmation belongs to a different session.",
  "reason": "confirmation_session_mismatch"
}

The reason key exists so a client can tell the two 423s apart. The plain "requires confirmation" 423 carries none and means earn one. This one means discard the one you are holding and earn another — matching on the English prose would be a drift surface of its own.

A step-up asserts "the person at this keyboard re-proved themselves just now". Bound to the subject alone it was instead bearer authority across every token that user held — so a machine token with a pinned grant, which can never earn a confirmation itself because the earning routes are ability-gated, could present the one the user's browser earned and act with it.

Rotation does not invalidate it. The binding is to the family, not to an individual access token, and a family survives refresh — so a token rotating mid-window keeps its confirmation. BFF mode is unaffected for the same reason. A new login does start a new family, and the BFF discards the stored confirmation when it captures a new token pair.

Verify-only and co-issuer topologies

Matching is strict, which is what keeps a co-issuer working: a token minted by another service sharing the secret carries no family, and neither does a confirmation earned by it, so the two still match. What is refused is an unbound confirmation presented by a token that does have a family.

Earning confirmation ​

With a password ​

http
POST /auth/confirm-password
Authorization: Bearer <access token>
Content-Type: application/json

{ "password": "secret" }
json
{ "confirmation_token": "..." }

A password step-up meets the account's own level (lukk 0.7.0), as the passkey one already did. The confirmation token binds and removes authenticators, which NIST SP 800-63B-4 §4.1.2.1 puts at "the maximum AAL currently available in the subscriber account"; §2.2.3 lets an AAL2 session reauthenticate with one factor. So confirm-password:

Session (amr / auth_time of the presenting token)AccountResult
carries mfa, signed in within confirm.mfa_max_age (one day)anyaccepted
anything else — no mfa, an mfa sign-in older than mfa_max_age, dated in the future or with no auth_time, or no amr claim at allnothing stronger than the passwordaccepted
anything elsea confirmed TOTP on a guard that enforces two-factor, or a user-verifying passkey (with features.passkeys on)refused — 422 on password

In the refused case the session confirms with a passkey that verifies the user, or signs in again with its second factor first. Three cases are worth spelling out:

  • A token with no amr counts as single-factor. The claim goes missing in ordinary ways — a refresh on a schema without the refresh_tokens.amr column, a session started by $user->startSession() (a magic link, a social login), a co-issuer's token — and treating its absence as "old rule" would let a password-only attacker wait one refresh. Migrate the amr column, or users with a second factor have to confirm with a passkey or sign in again after every refresh.
  • mfa ages out. §2.2.3 allows one-factor reauthentication within an AAL2 session, whose overall limit SHOULD be about a day, while mfa rides every rotation for the whole refresh_ttl. So the allowance lasts confirm.mfa_max_age (default 86400) after the sign-in, read from the token's auth_time. Older sessions confirm with a passkey or sign in again. An auth_time more than leeway ahead of the clock is no recent sign-in either, and neither is one before the epoch or one that isn't a number at all. A fractional auth_time, which a co-issuer may stamp (a NumericDate, OIDC Core §2), counts as the second it falls in, and a numeric string is read as its number. lukk stores the real sign-in time with the session, so refresh_ttl doesn't move it; a session whose row holds no time (signed in before lukk stored it) carries no auth_time after its next refresh, and its allowance fails closed until a fresh multi-factor sign-in (or a passkey step-up).
  • A single-factor passkey followed by a TOTP is ["pop","user","otp"] — two possession factors, not mfa — so that session is refused here too.

Changing the password applies the same rule (lukk 0.7.0), refusing on current_password. A passkey counts only with features.passkeys on for the guard. An enrolled TOTP counts only where the guard enforces two-factor: with features.two_factor off, login never asks for the code, so no session there could carry mfa and the step-up would be locked away for good. The refusal comes before the password is checked, so it spends no lockout attempt and says nothing about the password. Lukk::actingAs(), which presents no token, keeps the old rule.

A stored hash the active hasher refuses — another algorithm, or a $2b$ import with hashing.bcrypt.verify on — is a credential nobody can present, so it gets the ordinary 422 on password, not a 500, as at sign-in. A password over 72 bytes against a bcrypt hash is answered with its own 422 on password — "Reset your password to continue" — after the check has run, since bcrypt could verify only its first 72 bytes and earlier releases stored it truncated. See Password policy.

With a passkey ​

If passkeys are enabled, a passkey assertion earns the same token, so passkey-only users can step up too. Get the ceremony from the authenticated options route, then post the assertion:

http
POST /auth/confirm-passkey/options
Authorization: Bearer <access token>
http
POST /auth/confirm-passkey
Authorization: Bearer <access token>
Content-Type: application/json

{ "ceremony_id": "…", "credential": { … } }

Both endpoints return the same kind of confirmation_token. The credential is interchangeable only up to the account's level, though: from lukk 0.7.0 a passkey step-up must meet the highest level the account can reach (NIST SP 800-63B-4 §4.1.2.1), because this token is what binds and removes authenticators. A single-factor assertion — one without user verification, or with it on a credential that had not shown it before (not yet uvInitialized; see when a passkey counts as multi-factor) — is refused on an account that has a password or a user-verifying passkey, with a 422 on credential (not 403, which would tell the client the token may never confirm). The route takes no TOTP code. See Passkeys → Step-up with a passkey.

Throttling ​

Both endpoints are rate-limited by lukk-confirm (rate_limits.confirm, default 5 / 60s), keyed on both the user and the IP — the per-user bucket is the load-bearing one, since a caller holding a stolen access token is a single identity behind however many addresses they like. The same bucket also meters POST /auth/password and GET /auth/account/export. The passkey options route rides rate_limits.passkeys instead, per address only. A tripped limit returns 429.

Password confirmation re-verifies the same secret as login, so it also counts toward the opt-in account lockout under the confirm purpose; a locked step-up answers 423. The counter is cleared by a successful password confirmation, a successful password sign-in, a passkey sign-in that grants a session on a multi-factor assertion, a password change or reset, and an accepted passkey step-up — the last only after every check has passed, so a passkey refused for the account's level can't be alternated with password guesses to reset the count (lukk 0.7.0). confirm-passkey is throttled but never locked — an assertion is a signature, not a guessable secret, so there's nothing to cap.

All three earning routes — confirm-password, confirm-passkey and confirm-passkey/options — also carry RequirePinnedAbility:lukk.account: a pinned token without lukk.account gets 403 and can never earn a step-up. A human login is never refused by it.

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 order doesn't matter: lukk.confirm is sorted after authentication, so ['lukk.confirm', 'auth:api'] works too. Up to lukk 0.6 that order read no user and answered 423 to a valid confirmation.

The client then attaches the confirmation token to the request:

http
DELETE /account
Authorization: Bearer <access token>
X-Lukk-Confirmation: <confirmation token>

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),
    'mfa_max_age' => (int) env('LUKK_CONFIRM_MFA_MAX_AGE', 86400),
    'header' => env('LUKK_CONFIRM_HEADER', 'X-Lukk-Confirmation'),
],
KeyDefaultDescription
ttl300 (5 min)How long a confirmation token remains valid. Read per guard (lukk 0.7.0).
mfa_max_age86400 (1 day)lukk 0.7.0. How long after a multi-factor sign-in a password step-up is still accepted on an account with a second factor. Read per guard.
headerX-Lukk-ConfirmationThe header the middleware reads the token from — the setting of the guard that authenticated the request (lukk 0.7.0; it used to be read from the global block).

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<boolean> — is a confirmation currently held?
  required,         // Readonly<Ref<boolean>> — a withConfirmation() action is waiting on your modal
  token,            // Readonly<Ref<string | null>>
  confirmPassword,  // (password) => Promise<void>
  withConfirmation, // <T>(action: () => Promise<T>) => Promise<T> — per-action modal flow
  cancel,           // () => void — abort a pending withConfirmation
  clear,            // () => void
  record,           // ({ confirmation_token? }) => void — record a step-up earned by your own call
  abandonIfUnearnable, // (error) => void — on a 403, end a pending withConfirmation with that error
} = 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). You never thread the token through your own calls.

Where the token lives depends on the mode:

  • direct — the token is held on the Nuxt app (never in useState, which is serialised into the payload; lukk-nuxt 0.12.0) and the client attaches the header — to lukk's routes, and through useLukkFetch to your own API's origin too (lukk-nuxt 0.12.0; before, a lukk.confirm-gated route of your API answered 423 forever in direct mode). Never to another origin.
  • 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.

In bff mode this covers both proxies: the lukk auth routes and your own API through the app-API proxy. So a route on your Laravel API gated with lukk's lukk.confirm middleware works through useLukkFetch() once confirmed is true — the token is injected from the sealed session, and a confirmation header sent by the browser is discarded rather than forwarded.

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 (422). So does a password step-up refused for the account's level — a single-factor session on an account with a second factor — also a 422 on password; offer passkey confirmation there, or a fresh sign-in with the second factor. A 422 telling the user to reset means their password is over 72 bytes under bcrypt — send them to password reset. Call clear() to drop the confirmation early. It also clears on logout, and whenever the session changes hands (lukk-nuxt 0.12.0): a sign-in, here or in another tab, or a renewal that comes back as another account drops confirmed, the held token and any withConfirmation() still waiting. A step-up belongs to the session that earned it — before, the lukk-confirmed middleware let a newly signed-in account through on the previous one's step-up, and in direct mode its token rode the new account's requests.

In BFF mode, a confirmation can also reject with 409 when the session that asked for it was replaced by a sign-in (or ended by a logout, perhaps in another tab) while the request was out. The confirmation belonged to that session, so the proxy doesn't store it; re-confirm once the page shows the current session. If the session rotated while the step-up was out (another tab, a sibling request), the confirmation is recorded onto the session's newest pair (lukk-nuxt 0.12.0; it used to re-seal the tokens it arrived with). As a backstop, a 409 ("Your session was renewed meanwhile. Please confirm again.") answers a confirmation whose pair the session has moved past with no rotation journal link leading on. The auth proxy's calls give up after 15 s, inside every link's window, so only a journal that overflowed meanwhile gets here. Nothing is sealed from it, and the browser keeps its newer session cookie. No client retries it: the user confirms again, and the step-up is recorded on the newer session (a passkey asks for a fresh assertion).

Confirming with a passkey ​

A passkey can satisfy step-up too — see 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:

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
<script setup lang="ts">
const { required, confirmPassword, cancel } = useLukkConfirmation()
const password = ref('')
</script>

<template>
  <dialog :open="required">
    <input v-model="password" type="password" >
    <button @click="confirmPassword(password)">Confirm</button>
    <button @click="cancel">Cancel</button>
  </dialog>
</template>

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. A 403 from the confirm call means the token can never earn a step-up — a pinned machine token without lukk.account — so it ends the wait instead of leaving the modal open: the pending action rejects with that 403, not "confirmation cancelled". That holds across instances (lukk-nuxt 0.12.0): the modal calling confirmPassword() and the page waiting in withConfirmation() are usually two useLukkConfirmation() calls, and the error is now held per app rather than per instance, so the page sees it. A pending action is also cancelled when its session ends — on logout() and on every sign-in (lukk-nuxt) — so a confirmation earned later, perhaps by another account on the same tab, can never run the old action unasked. And when a confirmation lands while the action's request is still out — earned by another action's modal — the 423 that request comes back with answers the request, not that confirmation: withConfirmation retries once with it instead of clearing it and asking again (lukk-nuxt 0.12.0).

If you earn a step-up through a call of your own rather than confirmPassword() / useLukkPasskeys().confirm(), hand its result to record() so confirmed flips (and, in direct mode, the token is stored — in BFF mode the proxy keeps the token, and record() takes its { ok: true } acknowledgement as it is), and pass a failure to abandonIfUnearnable() so a 403 ends a pending withConfirmation() instead of leaving the modal open.

Per-page (a section) ​

To gate a whole page/section instead, use the lukk-confirmed 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