Login factor
Getting started with @railman/auth-login-factor — L0 stamps and requireAccess maps.
Getting started — Login factor
@railman/auth-login-factor stamps how the session was established and exposes requireAccess maps for L0 / L1 gates. Cookie lastLoginMethod is never an authorization input.
HTTP: none (plugin hooks + server helpers) · API helpers: API — privilege
Install
pnpm add @railman/auth-login-factorSetup
import { betterAuth } from "better-auth";
import {
AUTH_REQUIRE_PRESETS,
createElevateOpener,
loginFactors,
requireAccess,
} from "@railman/auth-login-factor";
const secret = process.env.BETTER_AUTH_SECRET!;
export const auth = betterAuth({
secret,
session: {
// Required: cookieCache would expose returned session fields to the client
cookieCache: { enabled: false },
},
plugins: [loginFactors()],
});
export async function assertPrivileged(session: {
userId: string;
sessionToken: string;
loginFactor?: string | null;
elevateClaim?: string | null;
}) {
await requireAccess({
secret,
session,
map: AUTH_REQUIRE_PRESETS.privilegedAction,
elevateOpener: createElevateOpener({
secret,
userId: session.userId,
sessionToken: session.sessionToken,
ttlSec: 300,
maxElevatedSec: 3600,
}),
// enrolled: { passkey, totp, password }, // server lookup for password AMR
});
}Ensure the Better Auth schema includes the optional loginFactor string field before enabling the plugin.
There is no client HTTP surface. loginFactorsClient() is a session-typing marker ($InferServerPlugin); stamp evaluation stays on the server. Client boundaries.
Presets
| Preset | Meaning |
|---|---|
sessionOnly | Signed L0 session |
elevateSudo | Session + elevate (distinct when required) |
privilegedAction | Elevate: passkey/TOTP; password only if no stronger enrolled |
recoveryControlPlane | Session + passkey/TOTP, distinct from session factor |
Maps must be server constants — never from request body/query.
The L0 stamp lives on the Better Auth session row (database). Disable session.cookieCache. See Shared storage.
With elevate
Install loginFactors() whenever you use requireDistinctInitialFactor or requireAccess with elevate AMR. Strict elevate setup rejects a missing login-factor plugin. Pass elevateOpener: createElevateOpener({ secret, userId, sessionToken }) into requireAccess whenever the map inspects elevate claims.