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({
deploymentMode: "single-instance",
// 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({
deploymentMode: "single-instance",
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 — mint-time: password re-enter only if no passkey/TOTP enrolled |
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 AMR is accepted only when the server passes enrollment with no passkey/TOTP. |
Client vs server
| 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.