Zero Accessby Railmandocs

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

ShapeLoginElevate methodsPackages
Password / TOTP onlyEmail password (or OAuth)Password, TOTPloginFactors, elevate, optional twoFactor
With passkeysPassword and/or passkeyPasskey, TOTP, passwordAbove + 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 mutation

Client flow:

  1. GET /elevate/methods — which factors are available
  2. POST /elevate/verify with { method: "password", password } or { method: "totp", totpCode }
  3. Session row carries the signed claim; UI may show elevatedAt / expiresAt only

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:

  1. GET /elevate/passkey/options
  2. navigator.credentials.get
  3. POST /elevate/verify with { method: "passkey", assertion }

Defaults that matter

OptionIntent
elevatedTtlSec300 (5 minutes); only successful re-verification refreshes it
maxElevatedSecAbsolute cap from first elevate (often 3600s)
passwordOnlyIfNoStrongertrue — mint-time: password re-enter only if no passkey/TOTP enrolled
passkeyCounterGuardRequired when passkey step-up is enabled
requireDistinctInitialFactorOptional mint-time 2FA style. Prefer per-action gates (elevateSudo, recoveryControlPlane) instead of locking mint.
elevate.passwordIfNoStrongerAction flag on requireAccess maps. Password AMR is accepted only when the server passes enrollment with no passkey/TOTP.

Client vs server

Client may showServer alone trusts
“Elevated for 4m” UISigned claim on the session row
amr labels for UXrequireElevate / 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 ceremony

Try the lab

DemoWhat it shows
/elevateSign in → elevate → privileged write. No vault.
/vaultFull 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.

On this page