Elevate and sudo
Step-up for privileged actions — with or without passkeys, never vault unlock.
Elevate and sudo
elevate() adds a short step-up window on an existing Better Auth session. Use it for GitHub-style sudo: delete account, change billing, rotate recovery, open an admin panel.
It does not open a vault. There is no MEK on this path.
Composition
To wire this path into an app, follow Step-up only or Passkey + step-up. This page is the ceremony and policy deep dive.
Live demo: Elevate lab — methods, layer board, gate matrix, config catalog. No vault.
Choose a shape
| Shape | Login | Elevate methods | Packages |
|---|---|---|---|
| Password / TOTP only | Email password (or OAuth) | Password, TOTP | loginFactors, elevate, optional twoFactor |
| With passkeys | Password and/or passkey | Passkey, TOTP, password | Above + enhancePasskey(passkey, …) |
Passkeys are optional. Start without them if you only need re-entry + TOTP. Full matrix: Use cases.
Without passkeys
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
import { elevate, requireElevate } from "@railman/auth-elevate";
import { loginFactors } from "@railman/auth-login-factor";
export const auth = betterAuth({
// database, secret, baseURL, email/password…
plugins: [
loginFactors(),
twoFactor(), // enroll/storage for TOTP; elevate does step-up verify
elevate({
// omit passkeyCounterGuard → passkey step-up off
// (or set allowPasskey: false to stay quiet)
passwordOnlyIfNoStronger: true,
elevatedTtlSec: 300,
maxElevatedSec: 3600,
}),
],
});Gate a privileged route:
import { requireElevate } from "@railman/auth-elevate";
await requireElevate(session.session.elevateClaim, {
userId: session.user.id,
sessionToken: session.session.token ?? session.session.id,
secret: process.env.BETTER_AUTH_SECRET!,
ttlSec: 300,
});
// then perform the privileged mutationClient flow:
GET /elevate/methods— which factors are availablePOST /elevate/verifywith{ method: "password", password }or{ method: "totp", totpCode }- Session row carries the signed claim; UI may show
elevatedAt/expiresAtonly
With passkeys
Passkey elevate reuses the same counter controller as login. Build one composition and pass the controller into elevate.
import { betterAuth } from "better-auth";
import { passkey } from "@better-auth/passkey";
import { twoFactor } from "better-auth/plugins";
import { enhancePasskey } from "@railman/auth-zero-access-passkey";
import { elevate } from "@railman/auth-elevate";
import { loginFactors } from "@railman/auth-login-factor";
const passkeyStack = enhancePasskey(passkey, {
rpID: "example.com",
origin: ["https://example.com"],
assertCredentialAccess: "session", // upgrade to a step-up assertion
});
export const auth = betterAuth({
plugins: [
...passkeyStack.plugins,
loginFactors(),
twoFactor(),
elevate({
passkeyCounterGuard: passkeyStack.controller,
passwordOnlyIfNoStronger: true,
elevatedTtlSec: 300,
}),
],
});Passkey step-up:
GET /elevate/passkey/optionsnavigator.credentials.getPOST /elevate/verifywith{ method: "passkey", assertion }
Defaults that matter
| Option | Intent |
|---|---|
elevatedTtlSec | 300 (5 minutes); only successful re-verification refreshes it |
maxElevatedSec | Absolute cap from first elevate (often 3600s) |
passwordOnlyIfNoStronger | true at mint — optional. Prefer leaving mint open and rejecting weak methods on the action (requireAccess / amrAnyOf). |
allowOtp / otpOnlyIfNoStronger | Opt-in email OTP; when enabled, hide OTP if passkey/TOTP enrolled (default). |
passkeyCounterGuard | Required when passkey step-up is enabled |
requireDistinctInitialFactor | Optional mint-time 2FA style. Prefer per-action gates (elevateSudo, recoveryControlPlane) instead of locking mint. |
elevate.passwordIfNoStronger | Action flag on requireAccess maps. Password or email OTP AMR is accepted only when the server passes enrollment with no passkey/authenticator. |
Mint vs action
TOTP is an authenticator (optional elevate method), not a requirement to mint a claim. Password and email OTP can mint. The verify JSON is UI-only and includes method plus amr. Authorize on the session claim:
// Soft sudo — any fresh claim
await requireElevate(session.session.elevateClaim, {/* … */});
// Vault / recovery: reject password and email OTP unless listed
await requireElevate(session.session.elevateClaim, {
/* … */
amrAnyOf: ["passkey", "totp"],
});Client: GET /elevate/methods → pick an allowed method → POST /elevate/verify → show method / amr for UX → privileged routes re-read the signed session claim.
Email OTP: allowOtp: true + sendElevateOtp + otpOnlyIfNoStronger: true (default) so OTP appears only when no passkey/TOTP is enrolled.
import { requireElevate } from "@railman/auth-elevate";
import {
AUTH_REQUIRE_PRESETS,
requireAccess,
} from "@railman/auth-login-factor";
// Any fresh claim (billing, settings, “sudo”)
await requireElevate(session.session.elevateClaim, {
userId: session.user.id,
sessionToken: session.session.token,
secret: process.env.BETTER_AUTH_SECRET!,
ttlSec: 300,
});
// Vault / recovery: reject password (and would reject SMS) unless you list it
await requireElevate(session.session.elevateClaim, {
userId: session.user.id,
sessionToken: session.session.token,
secret: process.env.BETTER_AUTH_SECRET!,
ttlSec: 300,
amrAnyOf: ["passkey", "totp"],
});
await requireAccess({
session,
map: AUTH_REQUIRE_PRESETS.privilegedAction, // passkey|totp, or password if nothing stronger enrolled
elevateOpener,
enrolled,
});Authenticator enrollment is optional. Mint with password (or passkey); vault maps decide whether that method is enough.
Client vs server
elevateClient talks to /elevate/* only. It is not a vault client and does not persist MEKs. Client boundaries.
| Client may show | Server alone trusts |
|---|---|
| “Elevated for 4m” UI | Signed claim on the session row |
| amr labels for UX | requireElevate / requireAccess with secret + session token |
Never pass verify-response JSON into requireElevate. Always load the claim from the session.
Distinct factor (optional)
When privileged actions must use a different factor than login, install loginFactors(), set requireDistinctInitialFactor: true, and gate with requireAccess + a server-constant map (AUTH_REQUIRE_PRESETS).
Maps must be server constants. Never build them from query, body, or headers.
What elevate must not do
// Wrong — elevating is not unlocking
if (elevateOk) await unlockVaultFromSession();
// Right — elevate gates product privilege only
if (elevateOk) await deleteBillingMethod();
// vault stays locked until a separate L2 ceremonyTry the lab
| Demo | What it shows |
|---|---|
| /elevate | Sign in → elevate → privileged write. No vault. |
| /vault | Full ceremony where elevate is one step among many |
Hosted lab data can be wiped. Use it to learn the control plane, not as production storage.