effect-auth

Step-up Authentication

Require stronger or fresher authentication before a sensitive action.

Step-up reauthenticates the current session user before a sensitive action. Successful verification keeps the logical session ID, compare-and-set rotates its opaque secret, invalidates the old token, and commits a replacement cookie.

A protected action is retried only after factor verification rotates the session

Step-up is not login MFA

MFA uses a flowId before a session exists. Step-up requires a valid session cookie, derives the user from it, and has no login flowId.

HTTP Operations

HTTP Operations preserve built-in factor verification and rotation while letting the application choose routes and app guards.

The focused APIs are opt-in. CoreAuthHttpApiLive does not mount Step-up routes, although createAuthClient() exposes auth.stepUp as part of its client-only protocol superset.

Built-in contract

All routes authenticate from the session cookie and accept no user ID.

RouteRequirementSession effect
GET /auth/step-up/optionsSessionLists active current-user factor families
POST /auth/step-up/password/verifyPasswordRefreshes pwd; not independently AAL2
POST /auth/step-up/totp/verifyTOTPAdvances counter, adds totp, can derive local AAL2
POST /auth/step-up/recovery-code/verifyRecovery codeConsumes code, adds recovery_code and remediation
POST /auth/step-up/passkey/startSessionStarts a current-user WebAuthn challenge
POST /auth/step-up/passkey/verifyVerified-UV passkeyUpdates credential and can derive local AAL2

Options report available factor families, not which ones satisfy an action. Intersect them with server-owned policy. The UI spelling is backup-code, adaptive capability is recovery-code, and AMR evidence is recovery_code.

Challenge and retry

Use one same-origin client in a Cloudflare frontend. Retry the protected operation with the same idempotency key; never trust a client-side “step-up succeeded” flag.

src/auth/confirm-payout.ts
import { createAuthClient } from "@effect-auth/core/Client";

const auth = createAuthClient();

export async function confirmPayout(input: {
  attemptId: string;
  totpCode?: string;
}) {
  const attempt = () =>
    fetch("/api/payouts/confirm", {
      method: "POST",
      credentials: "include",
      headers: { "Idempotency-Key": input.attemptId },
    });

  const first = await attempt();
  if (first.status !== 403) return first;

  const error = await first.clone().json();
  if (error.code !== "step_up_required") return first;

  const { factors } = await auth.stepUp.options();
  if (factors.some((factor) => factor.type === "passkey")) {
    await auth.stepUp.passkey.verify();
  } else if (
    input.totpCode !== undefined &&
    factors.some((factor) => factor.type === "totp")
  ) {
    await auth.stepUp.totp.verify({ code: input.totpCode });
  } else {
    throw new Error("No approved strong factor is available");
  }

  return attempt();
}

The aggregate client performs passkey start, WebAuthn, and finish in one call. createStepUpClient exposes separate passkey start and verify operations.

Policy model

ValueMeaning
aalAssurance derived from the complete trusted evidence set
amrCanonical method such as pwd, totp, recovery_code, passkey
authTimeLatest authentication-event time
mfaVerifiedAtLatest ordinary stronger-factor verification; recovery does not set it

sensitiveActionStepUpPolicy accepts password, passkey, or AAL2 evidence no older than 15 minutes and rejects recovery_remediation. Standard passkey management and revocation of other sessions use it.

payout-policy.ts
import * as StepUp from "@effect-auth/core/StepUp";
import { Duration } from "effect";

export const payoutPolicy = StepUp.session({
  require: StepUp.every(
    StepUp.aal("aal2", { maxAge: Duration.minutes(5) }),
    StepUp.oneOf(
      StepUp.amr("totp", { maxAge: Duration.minutes(5) }),
      StepUp.amr("passkey", { maxAge: Duration.minutes(5) })
    )
  ),
  noSessionRequirements: ["email_verification", "recovery_remediation"],
});

Method freshness uses the latest event for that method; factor freshness uses a stable factor ID. Password refreshes evidence and can complete an existing independent factor set, but is not itself AAL2. Recovery constrains derived AAL2/AAL3 to AAL1 until remediation completes. These are local policy tiers, not certification labels.

Rotation boundaries

MethodAtomic boundary
PasswordVerify credential, then CAS session rotation
TOTPAccept counter and rotate session atomically
RecoveryConsume code, add remediation, and rotate atomically
PasskeyConsume challenge/update sign count, then rotate session

Passkey state can commit before a later session race. For every method, storage rotation precedes cookie delivery; if delivery fails, the old token is already invalid. Define a safe full-reauthentication path and never return the replacement token in JSON.

Authenticate and authorize the current request.
Evaluate server policy against trusted session evidence.
Return step_up_required without exposing policy details.
Verify one approved factor for the current session user.
Rotate the session and commit the replacement cookie.
Retry the action with the same idempotency key.

Apply action-specific limits independently of factor-route limits. Test exact freshness boundaries, concurrent rotation, replay, old-token rejection, cookie failure, and idempotent retry. Never log passwords, codes, WebAuthn payloads, cookies, action bodies, or hostile headers.

HTTP errors

ErrorBoundary behavior
StepUpRequiredSanitized 403 step_up_required; retain details server-side
CustomEvidencePolicyErrorSanitized 500 internal_error; it is an integrity failure
Invalid factor401 invalid_credentials or endpoint-safe 400
Missing/raced session401 unauthenticated
Rate limit429 rate_limited

On this page