Flows
WIPSign-in and sign-out, step-up, password reset, email verification, account deletion with undo, staged sign-up, impersonation, and provider linking
auth.flows runs the multi-step operations. Each one composes providers, sessions and identities, and answers the intents your server adapter applies to the response: cookies to set or clear, a redirect, a JSON body.
Signing in and out
const { session, sid, intents } = await auth.flows.signIn({
providerId: 'password',
input: { email: 'ada@example.com', password: 'correct-horse-battery' },
previousSid, // the sid the request arrived with, if any; it is revoked
ip,
userAgent,
})
const { intents: cleared } = await auth.flows.signOut(sid)
signInhandsinputto the provider'scomplete, mints the session through rotation, and emitssignin.success.sessionisnullandsidempty when the provider answered with something other than a session, such as an OAuth redirect.- An unknown
providerIdthrowsAUTH_PROVIDER_FAILED. signOutrevokes the session, ignores one already gone, and answers the intents that clear the transport.
auth.flows.beginProvider(id, input) runs a provider's first leg: the magic-link mail, the OAuth redirect, the passkey challenge.
Step-up
Enrolling a second factor does not change sign-in, which still answers an AAL 1 session. A privileged route asks for more with checkStepUp:
const need = await auth.flows.checkStepUp(session, { aal: 2, methods: ['totp'], freshness: 5 * 60_000 })
if (!need.satisfied) {
// need.reason: 'mfa-required' | 'fresh-required'; need.methods: what would satisfy it
}
const stepped = await auth.flows.completeStepUp({ currentSid: sid, method: 'totp', code, ip, userAgent })
stepped.session.aal // 2
completeStepUp takes 'totp', 'backup-code', or 'webauthn' with a webauthn response, is rate limited per identity, and rotates to an AAL 2 session. A wrong code throws AUTH_INVALID_CREDENTIALS. See Adding MFA.
Password reset
await auth.flows.requestPasswordReset({
input: { email },
findIdentityByEmail: (email) => orNull(auth.identities.getByEmail(email)),
})
await auth.flows.completePasswordReset({ token, newPassword })
- The request answers
{ ok: true }whether or not the address exists, and mails apassword-resetlink through Delivery when it does. - Completing it spends the token, revokes every session of the account, then sets the password. A bad token throws
AUTH_RECOVERY_TOKEN_INVALID, an old oneAUTH_RECOVERY_TOKEN_EXPIRED. - When the account has TOTP or WebAuthn MFA, a mailed link alone is not enough: it throws
AUTH_RECOVERY_REQUIRES_MFAwith themethods, until the call carriescurrentSidof a fresh AAL 2 session of the same account. That session is rotated rather than revoked, and its replacement comes back inintents.
Email verification
await auth.flows.requestEmailVerification({ identityId: identity.id })
const { identity: verified } = await auth.flows.completeEmailVerification({ token })
verified.emailVerified // true
The request is rate limited per identity and does nothing for an address already verified, answering the same either way.
Account deletion
Deletion is confirmed by mail, lands as a soft delete, and can be undone inside the grace window:
await auth.flows.requestAccountDeletion({ identityId: identity.id, reason: 'user asked' })
const deleted = await auth.flows.completeAccountDeletion({ token, sendUndoLink: true })
// deleted.restorableUntil, deleted.cancellationToken
await auth.flows.cancelAccountDeletion({ token: deleted.cancellationToken })
completeAccountDeletionsoft-deletes the identity and revokes its sessions. WithsendUndoLinkit mails anaccount-deletion-cancellink; without it, delivercancellationTokenyourself or drop it to turn undo off.cancelAccountDeletiontakes either the undo token (the user's route) or{ identityId, authorize }(an operator's). Both or neither throwsAUTH_MISCONFIGURED.- Once the window closes,
identities.gc()erases the row.
Staged sign-up
For a sign-up that spans several screens, beginSignUp creates an unverified identity and answers a flow token the client keeps:
const { flowToken } = await auth.flows.beginSignUp({
email: 'lin@example.com',
required: ['email-verified', 'terms-accepted'], // the default
})
await auth.flows.advanceSignUp({ flowToken, stage: 'email-verified' })
await auth.flows.advanceSignUp({ flowToken, stage: 'terms-accepted', profilePatch: { username: 'lin' } })
const { session, intents } = await auth.flows.completeSignUp({ flowToken })
- Stages are
email-collected,email-verified,profile-completed,mfa-enrolled,terms-acceptedandcompleted. Your code proves each one before advancing it;advanceSignUprecords it. completeSignUpthrowsAUTH_SIGNUP_INCOMPLETEwith themissingstages, otherwise merges the collected profile and signs the user in.- The flow expires 30 minutes after its last step and 24 hours after it began. A dead or spent token throws
AUTH_SIGNUP_TOKEN_INVALID.getSignUpFlow(flowToken)reads the flow, and.orNull()answersnullfor a dead one. profilePatchcannot change the email the flow began with.
Impersonation
Support staff can act as a user through a separate, audited session:
const acting = await auth.flows.impersonate({
realSid: operatorSid,
targetIdentityId: user.id,
reason: 'ticket 4412',
authorize: async (operator, targetId) => canImpersonate(operator.identityId, targetId),
})
acting.session.actingAs // { realIdentityId, reason, startedAt, expiresAt }
const back = await auth.flows.releaseImpersonation(acting.sid)
authorizeis mandatory and decides; with duck-iam it wrapsengine.authorize(). Never passasync () => true.- The session lives at most an hour, emits
identity.impersonated, and cannot impersonate again or target the operator. releaseImpersonationends it and mints the operator a session of their own, or answerssession: nullwhen the operator's account went away meanwhile.
Linking a provider
await auth.flows.linkProvider({
identityId: identity.id,
providerId: 'oauth:github',
providerSub,
authorize: async ({ identity }) => identity.id === signedInIdentityId,
})
await auth.flows.unlinkProvider({ identityId: identity.id, providerId: 'oauth:github' })
A link is a permanent way to sign in, so authorize must answer false unless providerSub came from a token exchange your own code performed. unlinkProvider refuses the last way in unless allowLockout is set, which is for deletion and admin overrides.