Status: shipped 2026-08-04.
Signet's sensitive self-service routes already require a session younger than [session] fresh_age (86,400 seconds by default). Step-up reverification lets a stale but still live session prove a real factor and satisfy that same gate without falsifying the session's public createdAt or extending its expiry.
When a sensitive route returns HTTP 403 SESSION_NOT_FRESH, inspect the session's current options:
const status = await fetch("https://auth.example.com/api/auth/reverify", {
credentials: "include",
}).then((response) => response.json())
// {
// fresh, verifiedAt, freshUntil, reverificationId,
// level, requiredLevel, strategies
// }freshUntil is the earlier of verifiedAt + fresh_age and the session's existing expiry. A reverification never lengthens the session. reverificationId is null for the original sign-in freshness and rotates to a new random value after each in-place reverification.
Password
For an account without configured MFA:
const result = await fetch("https://auth.example.com/api/auth/reverify/password", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ password }),
}).then(async (response) => ({ response, body: await response.json() }))A correct password returns {status:true,reverification:{id,level,verifiedAt,freshUntil}}; retry the sensitive request with the same cookie. This route is deliberately distinct from better-auth's POST /verify-password: that compatibility route itself requires a fresh session and is not a stale-session escape hatch.
If verified two-factor is configured, password step-up returns HTTP 403 SECOND_FACTOR_REQUIRED. Signet requires the strongest available level instead of silently downgrading an MFA account to password-only proof.
TOTP, email OTP, and backup codes
The existing authenticated two-factor verification routes are also the step-up wire:
- TOTP:
POST /two-factor/verify-totpwith{code}. - Email OTP: first
POST /two-factor/send-otp, thenPOST /two-factor/verify-otpwith{code}. Email OTP is advertised only when a delivery channel exists. - Backup code:
POST /two-factor/verify-backup-codewith{code}. A successful code remains single-use.
Each successful authenticated factor marks only the current session second_factor; it does not rotate the cookie or change the existing better-auth response body. Sign-in challenge behavior is unchanged.
Passkeys: in-place confirmation (SIG-240)
Status: added 2026-10-11.
A person with a registered passkey can confirm the session they already hold. GET /reverify advertises passkey_in_place for any account with a live passkey, with or without a second factor. A passkey answer with user verification (Touch ID, Face ID, a device PIN or a key's PIN) proves something the person has and something they are or know, so this confirmation counts at the second_factor level and meets requiredLevel for every account.
A passkey only answers on its relying party's own site, and Signet's relying-party id is its own host. So the ceremony runs on a page Signet serves, and the session holder starts and finishes it. The flow has three steps:
- Open a request (session holder).
POST /reverify/passkey/requestwith the session (cookie orAuthorization: Bearer). The body is optional (see "Saying what is being confirmed" below). The answer is{requestId, confirmUrl, expiresAt}. Signet ties the request to this exact session and person. It lives five minutes. - The person approves (browser). Send the person to
confirmUrl, for example in a new window. That page is on Signet's own origin: the passkey host when[server] passkey_hostis set (new passkeys live there), otherwisebase_url's host. A passkey made onbase_url's host still confirms on the same page at that host. It names the account, asks for the passkey with user verification required, and accepts only that account's own passkeys. It changes no session and sends no cookie. When the page was opened by script, it posts{type: "reverify-passkey", status: "approved"}to its opener (no secret in it) and closes. - Redeem (same session holder).
POST /reverify/passkeywith the same session and{requestId}. Signet spends an approved request once and marks the current session reverified:
const result = await fetch("https://auth.example.com/api/auth/reverify/passkey", {
method: "POST",
headers: {
authorization: `Bearer ${sessionToken}`,
origin: "https://auth.example.com",
"content-type": "application/json",
},
body: JSON.stringify({ requestId }),
}).then((response) => response.json())
// {status: true, reverification: {id, level: "second_factor", verifiedAt, freshUntil}}The receipt has the same shape as the password re-check's. Signet mints no session, keeps the cookie, and never switches the account. Until the person approves, redemption answers HTTP 409 REVERIFICATION_PENDING, and the request stays open, so a relying service may retry it. These answers are refusals, and none of them marks the session:
REVERIFICATION_REQUEST_INVALID(403): the request is unknown, expired, already redeemed, or belongs to another session. Even another session of the same person cannot redeem it.REVERIFICATION_PASSKEY_OTHER_ACCOUNT(403, from the page): another account's passkey answered.REVERIFICATION_PASSKEY_REJECTED(403, from the page): the answer failed, or had no user verification.REVERIFICATION_ORIGIN_REFUSED(403): the ceremony calls came from any origin other than Signet's own. Only the confirmation page may make them.PASSKEY_REQUIRED(400, at step 1): the account has no live passkey.REVERIFICATION_IMPERSONATION_FORBIDDEN(403): the session is an impersonation session.
A refused answer spends that challenge. The page asks for a new one when the person presses Confirm again. Each refusal body carries code, message (what to do, for a program) and human (one plain line for a person).
Say what the person confirms (SIG-307)
Status: added 2026-10-17.
Without a description, the confirmation page says only that the person must confirm. Someone who holds a person's session can open a request for an act of their choice and send the person the genuine link. So a relying service should name the act when it opens the request:
{"act": "Ratify grove:seeds/seed-14: Passkeys need a second step", "service": "Grove"}act: what the person confirms, up to 200 characters.service: the name of the service that asks, up to 60 characters.
Both fields are optional. The page shows them above the passkey button, before any passkey call: "Grove asked you to confirm this:", then the act in bold. Without service it says "An app you are signed in to". The page also tells the person to close it if the words do not match what they do now.
Signet stores both with the request. Nothing can change them later, and each request shows only its own text. The answer echoes act and service when you send them. An engine older than this change ignores the body and echoes neither. So check the echo before you rely on the page.
Both are plain text. The page escapes them, so markup shows as typed. Signet refuses the request with HTTP 400 when:
REVERIFICATION_ACT_TOO_LONGorREVERIFICATION_SERVICE_TOO_LONG: the text is over its limit.REVERIFICATION_ACT_INVALIDorREVERIFICATION_SERVICE_INVALID: the text is blank, or holds characters that hide, reorder or overlay text (control, line-break, bidirectional, invisible and similar). An approval request'scontextuses the same rule.REVERIFICATION_REQUEST_BODY_INVALID: the body is not empty and is not a JSON object with only these two string fields.
Signet cannot check either text, because whoever holds the session chooses both. So the page labels them: "These are the app's own words. Signet cannot check them, or the app's name." A request with no body, or with {}, opens as before.
Passkey sign-in is still an account switch
The compatibility /passkey/generate-authenticate-options and /passkey/verify-authentication ceremony is unchanged, and GET /reverify still lists it as passkey for a non-MFA account. That flow is an account switch, not an in-place step-up: it mints a new session for the user who proved the passkey. A session-bound challenge is only a request receipt. If the authenticator presents another resident credential, the new session belongs to that other account. Do not describe this path as confirming the current session. Use passkey_in_place for that.
A session-bound registration challenge presented to verify-authentication is a typed refusal and burns the challenge; it does not switch accounts.
Other sign-in methods
An account with no password, no passkey and no second factor receives an empty strategy list for in-place step-up. Re-authenticate through its ordinary social or SSO flow to create a fresh session. Signet does not invent a weaker fallback factor.
Guessing and replay boundary
Five failed password or TOTP submissions inside ten minutes lock step-up on that session for ten minutes. The counter is stored on the session row and updated with guarded database writes, so multiple Signet processes share one budget. Delivered OTP has its existing five-attempt budget per single-use code; backup codes are high-entropy and single-use. All routes retain the ordinary request limiter. Password failures also retain the account lockout policy; step-up is not a route around sign-in protection. Successful verification clears session attempt state. retry-after accompanies REVERIFICATION_LOCKED.
The receipt ID is reusable throughout the freshness window; Signet's own sensitive routes require freshness, not one-receipt-per-action consumption. An application which needs payment-style dynamic linking can store reverificationId with the amount/payee and reject reuse itself. Do not describe the default as one-time transaction authorization.
The in-place passkey path adds one more boundary. Only the session that opened a request can redeem it, and only once. Each redemption writes a new reverificationId. So a relying service that binds one receipt to one act gets a new receipt per passkey confirmation. A replayed requestId never produces a second receipt. Failed passkey answers do not count against the five-guess budget, because a passkey cannot be guessed. A session whose step-up is locked by failed password or TOTP guesses cannot open a passkey request until the lock ends. A successful redemption clears the budget, as every successful factor does.