Skip to content

Member authentication and MFA with Descope

The member backend exposes authentication as a set of alternative complete sign-in paths. Descope remains the source of truth for enrolled credentials and for the native amr claim on session tokens.

Authentication methods

Enable these methods in the Members Descope project:

Method Descope capability Backend use
Password Password Default sign-up and sign-in
Passkey WebAuthn Passwordless sign-in and optional account hardening
SMS OTP SMS Additional method on the password path
Email OTP OTP Email Email verification, and the password-path second-factor fallback (Password fallback MFA)

Passkey enrolment supports:

  • platform: a device authenticator such as Apple Touch ID.
  • cross_platform: a multi-device credential provider such as 1Password.

Members may enrol more than one passkey credential.

Sign-in flow

There is deliberately no email-keyed "what can this account do" endpoint — it would let anyone enumerate which accounts exist and how they sign in. The client establishes a session from three signals instead:

  1. SSO branch — after the member enters their email, the client probes GET /members/sso/is-sso-user. It reveals only whether the account is federated (an organisation-level fact, not personal factors); when it is, the client hands off to the IdP with the returned ssoOrganizationId.
  2. Passkey — offered purely from the device's own WebAuthn capability (no server lookup). When the device can use a passkey it is the primary call to action; POST /members/auth/passkey/{start,finish} runs the ceremony for the entered email.
  3. Password — always available as the fallback (POST /members/sign-in). The client does not know up-front whether a second factor is required: it reads security.session.authenticated from the sign-in response. false means the password session is half-authenticated and a second factor is still required. Which factor is read from security.signInOptions — the [password, …] path names it: sms (POST /members/auth/sms/{start,verify}) or email_otp (POST /members/auth/email-otp/{start,verify}). See Password fallback MFA.

Because the client never asks "does this account exist / what does it have", an unknown email simply fails the password attempt like any wrong credential.

Sign-in scenarios

Every member enters their email first. What happens next is driven entirely by the enrolled factors below — there is no per-account lookup; the client reads security.signInOptions from the sign-in response (and the device's own passkey capability) to know which paths are complete. email_otp is not an enrolled factor — it is the guaranteed password-path fallback that appears only once a passkey is enrolled (see Password fallback MFA).

Passkey SMS phone signInOptions (complete paths) Sign-in flow Second factor after password
["password"] Email → Password → straight to the dashboard None (account is not MFA-grade)
["password","sms"] Email → Password → enter SMS code SMS OTP
["passkey"], ["password","email_otp"] Email → Passkey (one tap, done) — or Password → enter emailed code Email OTP
["passkey"], ["password","sms"] Email → Passkey (one tap, done) — or Password → enter SMS code SMS OTP
SSO member ["sso"] Email → is-sso-user hit → redirect to the identity provider Delegated to the IdP

Reading the table:

  • Passkey alone is a complete sign-in. When a passkey is enrolled and the device can use one, it is the primary call to action and finishes in a single ceremony (amr = ["webauthn"] satisfies the ["passkey"] path). Password is always there as the fallback.
  • Enrolling a passkey makes the account MFA-grade. From then on, password alone is never sufficient: it must be elevated with SMS (if a phone is enrolled) or Email OTP (the always-available fallback). The two are mutually exclusive — SMS wins when present; there is no cascade to email.
  • No passkey and no SMS = no second factor. Password signs the member straight in; the account has not opted into MFA.
  • Device capability is a presentation concern only. A passkey-enrolled member on a non-passkey device simply uses the password path (and its second factor); the backend reports the same options regardless (see Method presentation by device capability).
  • Recovery is a planned fallback, not yet built. If the required second factor cannot be completed (lost phone, no email access), the only way forward today is account recovery — see the Recovery note below.

Password fallback MFA

A passkey is strong, phishing-resistant auth. Once a member has enrolled a passkey, we treat their account as MFA-grade: signing in with password alone must not be enough — it always has to be elevated with a second factor. Which factor depends on what the member has:

Enrolled factors Password sign-in path Second factor
passkey + SMS phone ["password", "sms"] SMS OTP
passkey, no SMS phone ["password", "email_otp"] Email OTP
SMS phone, no passkey ["password", "sms"] SMS OTP
neither ["password"] none

email_otp is the fallback that guarantees a second factor is always available for a passkey member: every member has a verified email, so there is nothing to enrol — it is a sign-in factor, not an enrolment option, and so it appears in security.signInOptions (a complete ["password","email_otp"] path) but not in security.mfa.options (which lists only enrolable methods: passkey and SMS).

SMS and email OTP are mutually exclusive — there is no cascade. A member's password path offers exactly one second factor: SMS when a phone is enrolled, email OTP only when one is not. So a member with SMS enrolled is never offered email OTP; if their SMS step fails (lost phone, no signal, wrong code), there is no alternative factor and the sign-in stops — their only way forward is account recovery.

The step-up itself mirrors SMS exactly — Descope's otp.signIn.email with { mfa: true } and the member's refresh token sends the code, otp.verify.email completes it, and the resulting session's native amr carries the email factor so session.authenticated becomes true.

Recovery (not yet implemented). If a passkey member can complete no second factor (lost phone and no access to email at that moment, etc.), the sign-in screen will offer a "Recover via email" action — a separate account-recovery flow, distinct from the email-OTP second factor. This is a placeholder; the flow will be specified and built separately.

Method presentation by device capability

The two optional methods are passkey and SMS. The frontend decides which to offer based on whether the device can create/use a passkey (a WebAuthn platform or roaming authenticator is available):

  • Passkey-capable device (iPhone, Mac, modern Android, Windows Hello): passkey is the primary call to action. "Use SMS instead" is offered as a secondary link for members who prefer or need it.
  • Non-passkey-capable device: SMS only. Passkey is not shown on devices that cannot use it.

This is a presentation concern only. The backend always reports both options under mfa.options with their enrolment state; it does not gate either on device capability.

Native Descope state

No custom MFA user attributes or custom JWT claims are used.

Authentication method Descope user field
Password password
Passkey webauthn
SMS verifiedPhone
Email OTP none — every member has a verified email; nothing to enrol
SSO SAML / OAuth plus tenant auth configuration

getDescopeAuthMethods is the single conversion point from these Descope fields to the backend AuthMethod vocabulary. Email OTP is not derived from a user field — it is offered as the password fallback (see Password fallback MFA).

The current session is accepted when its native Descope amr claim satisfies every method in at least one returned sign-in option. The amr values map as pwd→password, webauthn→passkey, sms→sms, email→email_otp, saml/oauth/oidc→sso. Therefore:

  • ["webauthn"] satisfies the passkey option.
  • ["pwd", "sms"] satisfies password plus the enrolled SMS factor.
  • ["pwd", "email"] satisfies the ["password", "email_otp"] fallback path.
  • A bare ["pwd"] does not satisfy a passkey member (who needs SMS or email OTP).

SMS enrolment and AMR continuity

Enrolment uses Descope's update-phone OTP flow via the SDK (@descope/core-js-sdk 2.63.0+):

  • Send: otp.update.phone.sms(loginId, phone, refreshToken, { mfa: true }).
  • Verify: otp.verify.sms(loginId, code) — a standard verify, unchanged.

Passing { mfa: true } with the member's refresh token on the send step stages the update as a step-up: Descope preserves the amr factors already on the refresh token and merges the SMS factor onto them. The session minted at verify therefore carries the complete native amr chain in one pass, so the backend does not rewrite JWTs, merge claims, or make a second confirmation. The same flow handles first-time enrolment and replacement of an existing phone.

Earlier SDK versions did not expose MFA intent on the phone-update call, so the backend called the Descope REST endpoints directly. That workaround was removed once Descope shipped the fix in core-js-sdk 2.63.0.

Account-security response

GET /members/security returns:

  • signInOptions: the alternative complete sign-in paths.
  • mfa.policy: not_eligible, optional, required, or optional_until — the member's overall policy, computed as the most strict of the per-option policies below (strictest first: required > optional_until > optional > not_eligible; between two optional_until the earlier deadline wins).
  • mfa.unmetEnrolmentRequirements: preconditions blocking enrolment of any method (e.g. EMAIL_VERIFIED); empty when the member can enrol.
  • mfa.options: the optional MFA options as an ordered list, most-preferred first (empty when not_eligible). Each entry is the complete authentication path it enables — ["passkey"], or ["password","sms"] since SMS requires the password — plus enrolled and its own policy. Per-option policy lets methods diverge — e.g. SMS can become required while passkey stays optional — and the top-level mfa.policy reflects the strictest. The factor is the last method. The list is forward-compatible: clients render entries in order and ignore any option whose factor they do not recognise. The enrolled SMS phone is included only on this endpoint (account settings); the security embedded in sign-up / sign-in responses never carries it.
  • session.methods: methods parsed from native Descope amr.
  • session.authenticated: whether the session satisfies any sign-in option.

Email verification is required before a member can enrol passkey or SMS. SSO members resolve to not_eligible; their MFA policy belongs to the upstream identity provider.

Backend endpoints

  • Password sign-in: POST /members/sign-in
  • SSO branch: GET /members/sso/is-sso-user
  • Passkey sign-in:
  • POST /members/auth/passkey/start
  • POST /members/auth/passkey/finish
  • SMS second factor (sign-in step-up):
  • POST /members/auth/sms/start
  • POST /members/auth/sms/verify
  • Email-OTP second factor (sign-in step-up, the passkey-member fallback):
  • POST /members/auth/email-otp/start
  • POST /members/auth/email-otp/verify
  • Passkey enrolment:
  • POST /members/security/mfa/passkey/enrol/start
  • POST /members/security/mfa/passkey/enrol/finish
  • SMS enrolment: src/bff/members/security/mfa/
  • SMS removal: POST /members/security/mfa/sms/disable

Relevant implementation:

  • src/schemas/auth/AuthenticationOption.ts
  • src/integrations/descope/helpers/getDescopeAuthMethods.ts
  • src/bff/middleware/descopeMemberMiddleware.ts
  • src/bff/members/security/mfa/assertCanEnrolMfaMethod.ts
  • src/integrations/descope/DescopeApi.ts