Flows
WIPHigh-level flows - signIn, signOut, signUp state machine, password reset, email change, impersonate.
What flows give you
The auth.flows facet sits above providers, sessions, identities, and
channels - it composes them into the multi-step journeys your app
actually runs.
await auth.flows.signIn({ providerId: 'password', input: { email, password } })
await auth.flows.signOut(ctx)
await auth.flows.beginPasswordReset({ email })
await auth.flows.completePasswordReset({ token, newPassword })
await auth.flows.beginEmailChange({ identityId, newEmail })
await auth.flows.completeEmailChange({ token })
await auth.flows.impersonate({ actorId, subjectId, reason })
await auth.flows.releaseImpersonation(ctx)
Each method returns Intent[] so the framework adapter can issue the
right wire effects (cookies, redirects, JSON).
Sign-up state machine
For sign-ups that require multiple steps (collect email -> send verification -> user clicks link -> choose password -> MFA enrolment), use the state machine API:
const state = await auth.flows.signUp.start({ email })
// -> { stateId, nextStep: 'verify-email' }
// User clicks the verification link
const stepped = await auth.flows.signUp.advance({
stateId: state.stateId,
step: 'verify-email',
input: { token },
})
// -> { nextStep: 'choose-password' }
// User chooses a password
await auth.flows.signUp.advance({
stateId: state.stateId,
step: 'choose-password',
input: { password },
})
// -> { nextStep: 'enrol-mfa' | 'complete' }
// MFA enrolment (if required by your config)
await auth.flows.signUp.advance({
stateId: state.stateId,
step: 'enrol-mfa',
input: { factor: 'totp', code },
})
// -> { nextStep: 'complete', identityId }
The state lives in your credentials store (with TTL), so a user can
abandon the sign-up at any step and pick up where they left off when
they click the verification link again.
Password reset
// Step 1: user enters their email on /forgot
await auth.flows.beginPasswordReset({ email })
// Always returns successfully - existence not leaked.
// Internally: if the email exists, send a single-use token via the
// configured email channel.
// Step 2: user clicks the link and enters a new password
await auth.flows.completePasswordReset({
token,
newPassword,
})
// -> throws AUTH/RECOVERY_TOKEN_INVALID, AUTH/RECOVERY_TOKEN_EXPIRED,
// or AUTH/RECOVERY_REQUIRES_MFA if MFA is enrolled.
// -> on success: rotates the password, revokes every session, mints a
// fresh session for the user.
For high-assurance identities (MFA enrolled), the reset path itself requires MFA - preventing email-only account takeover.
Email change
// Step 1: user requests email change
await auth.flows.beginEmailChange({
identityId,
newEmail,
})
// -> sends a confirmation link to both old and new email.
// Step 2: user clicks the link in the new email inbox
await auth.flows.completeEmailChange({
token,
})
// -> updates the identity's email, revokes other sessions, optionally
// sends a "your email changed" notice to the old address.
The two-step flow (confirm via new inbox) is required - otherwise an attacker who already has a session could swap the email to a domain they control and lock the legitimate owner out.
Impersonation
For admin / support workflows where one identity needs to act as another:
// Start impersonation
const intents = await auth.flows.impersonate({
actorId: ctx.identity.id, // the admin
subjectId: 'identity-to-impersonate',
reason: 'support-ticket-12345',
durationMs: 30 * 60 * 1000, // 30 minutes
})
// The runtime mints a special session with:
// - session.actorId = the admin's id (audit trail)
// - session.subjectId = the impersonated user
// - session.kind = 'impersonate'
// - session.aal capped at the admin's original AAL
// Release impersonation
await auth.flows.releaseImpersonation(ctx)
// -> revokes the impersonation session, restores actor's prior session.
Impersonation events emit:
session.impersonate-start { actorId, subjectId, reason }
session.impersonate-release { actorId, subjectId, durationMs }
Wire these to your audit log - every impersonation should be visible in the audit trail.
The admin's iam permission to impersonate is not duck-auth's
concern; gate it with auth.iam
or via your application's own role check before calling flows.impersonate.
Errors
| Code | When |
|---|---|
AUTH/SIGNUP_INCOMPLETE | flows.signUp.advance called for a state past its terminal step. |
AUTH/SIGNUP_TOKEN_INVALID | Verification link token is unknown or consumed. |
AUTH/RECOVERY_TOKEN_INVALID | Reset token unknown or revoked. |
AUTH/RECOVERY_TOKEN_EXPIRED | Reset token past TTL (default 1 hour). |
AUTH/RECOVERY_REQUIRES_MFA | Identity has MFA enrolled; reset requires MFA verify. |
AUTH/EMAIL_TAKEN | beginEmailChange target already belongs to another identity. |
AUTH/EMAIL_CHANGE_PENDING | beginEmailChange while a prior request is still active. |
AUTH/IMPERSONATE_FORBIDDEN | Caller lacks the actor permission. |
AUTH/IMPERSONATE_EXPIRED | Impersonation session past its absolute TTL. |