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:
- 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 returnedssoOrganizationId. - 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. - Password — always available as the fallback (
POST /members/sign-in). The client does not know up-front whether a second factor is required: it readssecurity.session.authenticatedfrom the sign-in response.falsemeans the password session is half-authenticated and a second factor is still required. Which factor is read fromsecurity.signInOptions— the[password, …]path names it:sms(POST /members/auth/sms/{start,verify}) oremail_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, oroptional_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 twooptional_untilthe 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 whennot_eligible). Each entry is the complete authentication path it enables —["passkey"], or["password","sms"]since SMS requires the password — plusenrolledand its ownpolicy. Per-option policy lets methods diverge — e.g. SMS can becomerequiredwhile passkey staysoptional— and the top-levelmfa.policyreflects 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 SMSphoneis included only on this endpoint (account settings); thesecurityembedded in sign-up / sign-in responses never carries it.session.methods: methods parsed from native Descopeamr.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/startPOST /members/auth/passkey/finish- SMS second factor (sign-in step-up):
POST /members/auth/sms/startPOST /members/auth/sms/verify- Email-OTP second factor (sign-in step-up, the passkey-member fallback):
POST /members/auth/email-otp/startPOST /members/auth/email-otp/verify- Passkey enrolment:
POST /members/security/mfa/passkey/enrol/startPOST /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.tssrc/integrations/descope/helpers/getDescopeAuthMethods.tssrc/bff/middleware/descopeMemberMiddleware.tssrc/bff/members/security/mfa/assertCanEnrolMfaMethod.tssrc/integrations/descope/DescopeApi.ts