Skip to content

Step-up authentication

Rakomi exposes step-up authentication — a short-lived re-auth token issued via one of four methods, used to gate sensitive actions (account-linking, passkey CRUD, future high-value mutations). The issued token carries an AAL claim (Authenticator Assurance Level, NIST SP 800-63-3) and an AMR claim (RFC 8176 Authentication Methods References) so future AAL-aware gates (including a risk engine) can enforce “this action requires AAL2”.

MethodEndpointAALAMR (RFC 8176)Notes
passwordPOST /v1/auth/step-up/password2pwdBody: { password }.
totp(issued via login MFA verify)2otpTime-based one-time password. Already in step-up token enum.
magic_linkPOST /v1/auth/step-up/magic-link/{initiate,verify}1mcaSingle-use 10-min token. Distinct phishing-warning email template.
email_otpPOST /v1/auth/step-up/email-otp/{initiate,verify}1otp6-digit code, 5-min TTL, hashed at rest. Distinct template.
passkeyPOST /v1/auth/step-up/passkey/{options,verify}2swk / hwkWebAuthn assertion. UP+UV both required for AAL2 — UV=false is rejected, never AAL1.

SMS step-up is intentionally NOT offered. SIM-swap and SS7 attacks reduce SMS to AAL1 with significant phishing risk. Use passkey or email-OTP instead. (FusionAuth supports SMS step-up; we deliberately diverge.)

When the SDK gives you a list of available step-up methods on a 401 account_linking/mfa_required body (available_methods array), prefer methods in this order — strongest first:

  1. passkey — phishing-resistant, AAL2, single click on supported devices.
  2. email_otp — faster than magic-link (no inbox roundtrip), AAL1.
  3. magic_link — last resort if no passkey AND user prefers click-link UX, AAL1.
  4. password — present only when the user has a password set; AAL2.

Order in the available_methods response is a hint — the server may personalize. The SDK exposes the list as MfaStepUpRequiredError.availableMethods in @rakomi/node 0.12.0+.

Two distinct acr-related vocabularies exist, and they must not be confused. The acr claim actually stamped into an issued session/token (see OIDC provider guide — acr_values_supported) is a bare, unprefixed string — aal1 / aal2 (and eidas_low / eidas_substantial / eidas_high for EUDI Wallet logins). The urn:cre8eve:rakomi:* ACR URI column below is a different, request-side-only format used exclusively inside the RFC 9470 WWW-Authenticate: acr_values="..." challenge header (see below) — a space-separated string, not a JSON array, and not the value your code will ever read back from an acr claim on a token. If you’re integrating against the acr claim on an issued token, use the “acr claim value” column, not “ACR URI”.

Rakomi aal claimNIST 800-63-3eIDAS LoAacr claim value (on the issued token)ACR URI (WWW-Authenticate challenge only)Methods
1AAL1Substantial-lowaal1urn:cre8eve:rakomi:aal1magic_link, email_otp
2AAL2Substantialaal2urn:cre8eve:rakomi:aal2password, totp, passkey-UV+UP
3 (reserved)AAL3(reserved — aal3 is never issued)urn:cre8eve:rakomi:aal3(roadmap — no AAL3 step-up method exists today)

3 (reserved AAL3) is a separate concept from eIDAS “High” assurance. eIDAS Level of Assurance (eidas_low / eidas_substantial / eidas_high) is issued today — independently of the AAL tier system above — whenever EUDI Wallet login succeeds and the verifier is configured for this environment. It does not require reaching “AAL3”; there is no code path today that ever stamps a bare aal3 acr value. See OIDC provider guide — acr_values_supported for the full, environment-dependent eIDAS vocabulary.

AAL1 step-up tokens MUST NOT satisfy gates that require AAL2. Today all step-up consumers accept any valid token; AAL-aware gates are on the roadmap. Server-side enforcement is via the aal claim on the issued token, which the SDK’s verifyStepUpToken reads for you.

NIST-strict reading would put password at AAL1 (single-factor knowledge). Rakomi treats password as AAL2 by industry-aligned convention — a documented divergence, to be revisited when risk-based auth data is available.

When a route protected by requireStepUpIfMfaEnabled rejects a request for lack of a step-up token, the response now also carries an RFC 9470 standard header alongside the legacy JSON body discriminator:

HTTP/1.1 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="urn:cre8eve:rakomi:aal2 urn:cre8eve:rakomi:aal1"
{
"error": {
"code": "account_linking/mfa_required",
"message": "MFA verification required before linking a new identity",
"details": {
"next_action": "verify_mfa",
"mfa_challenge_token": "mfa_step_up_required",
"available_methods": ["password", "passkey", "email_otp", "magic_link"]
}
}
}

RFC-compliant clients (future enterprise integrations, EUDI relying parties) can interop on the header; SDK consumers continue to use the JSON body discriminator (next_action + available_methods).

import { MfaStepUpRequiredError } from "@rakomi/node";
try {
await rakomi.link.initiate("google", { userToken });
} catch (err) {
if (err instanceof MfaStepUpRequiredError) {
// Server tells us which methods this user can satisfy. Order is a hint.
const methods = err.availableMethods ?? ["password"];
const choice = methods[0];
// …route the user to the corresponding /v1/auth/step-up/<method> flow…
// After verify, retry link.initiate() with the issued step_up_token in
// the X-Step-Up-Token header.
}
}
POST /v1/auth/step-up/email-otp/initiate
Authorization: Bearer <user-jwt>
X-API-Key: <tenant-api-key>
Content-Type: application/json
{ "action_hint": "Confirm linking your Google account" }
202 { "status": "sent", "email_masked": "b***@***.com" }
POST /v1/auth/step-up/email-otp/verify
Authorization: Bearer <user-jwt>
X-API-Key: <tenant-api-key>
Content-Type: application/json
{ "otp": "123456" }
200 { "step_up_token": "...", "token_type": "StepUp", "expires_in": 300 }

Then attach the token to the gated request:

POST /v1/users/me/link/google
Authorization: Bearer <user-jwt>
X-Step-Up-Token: <step_up_token>

Every step-up attempt emits one event from this family:

  • auth.step_up_magic_link_initiated — magic-link initiate
  • auth.step_up_email_otp_initiated — email-OTP initiate
  • auth.step_up_passkey_options_issued — passkey options
  • auth.step_up_issued — verify success (any method)
  • auth.step_up_failed — verify failure (any method)

Metadata shape: { step_up_method, aal, latency_ms?, ip_hash, ua_family, [reason] }. The metadata object is intentionally open-shaped (jsonb) so a future risk engine can attach risk_score / risk_decision without a schema migration.

  • Phishing resistance: distinct email templates from login flows. Subject
    • body explicitly state “sensitive action” + “do NOT click if you did not request this”.
  • Constant-time compares: token / OTP lookup runs the full hash compare even on structurally invalid input — no timing oracle for “token-was-issued” vs “format-invalid”.
  • Session binding: the verify request’s session JWT MUST match the session that initiated; mismatch → 401 with the same generic invalid_or_expired_token code.
  • Single-use enforcement: consumed_at flip is atomic.
  • Cross-tenant isolation: every challenge / token is tenant_id-scoped; cross-tenant assertion replay rejected (404, never 403).
  • AAL2 enforcement on passkey: UP=true AND UV=true both required — UV=false is rejected with passkey/step_up_required, NEVER issued at AAL1.
  • No SMS: SMS-based step-up is excluded by design (SIM-swap, SS7).
  • SDK errors referenceMfaStepUpRequiredError, MfaStepUpUnavailableError.
  • Account linking — the primary gated consumer of step-up tokens today.
  • Passkeys — registration + authentication ceremony for the passkey method.