Prerequisites
Before you can add MFA to a custom auth flow, you’ll need to complete the following steps:Build out primary authentication
Before integrating MFA, you need to already have a primary authentication flow built out. You can do so by following one of the integration guides:
Configure enforced MFA for an Organization
To configure MFA, you’ll need to first toggle on “Require MFA” for at least one Organization in the Stytch Dashboard, or call the Update Organization API with
mfa_policy set to REQUIRED_FOR_ALL.Each Organization can specify which mfa_methods are allowed for Members in their Organization: either ALL_ALLOWED or RESTRICTED. If RESTRICTED, Members can only use MFA methods specified in the allowed_mfa_methods array.For example, if an Organization requires Members to use TOTP MFA:Report incorrect code
Copy
Ask AI
{
"mfa_policy": "REQUIRED_FOR_ALL",
"mfa_methods": "RESTRICTED",
// Optional, not enforced if mfa_methods is ALL_ALLOWED
"allowed_mfa_methods": ["totp"]
}
Integrating MFA
Stytch’s headless and backend SDKs provide a flexible way to integrate MFA into your authentication flow.- Headless frontend SDK
- Backend SDK
- First-time enrollment
- Returning login
- Optional enrollment
Required MFA enrollment
When an Organization’s MFA policy isREQUIRED_FOR_ALL but the Member is not currently enrolled in MFA, you’ll need to handle MFA enrollment after primary authentication.Detect when MFA enrollment required
The following authentication methods will return a Member Session if the Member is not enrolled in MFA OR an Indicating that
intermediate_session_token and an mfa_required object that indicates MFA is required in order to be granted a Session for the Organization:- sso.authenticate()
- oauth.authenticate()
- magicLinks.authenticate()
- passwords.authenticate()
- discovery.intermediateSessions.exchange()
- passwords.resetByEmail()
- passwords.resetByExistingPassword()
Report incorrect code
Copy
Ask AI
{
"intermediate_session_token": "oNJB3foIA79dn_uNVMNghG_MGkKSLHnR65NsKXv0gZzY",
"mfa_required": {
"member_options": null,
"secondary_auth_initiated": null
},
"member_authenticated": false,
"organization": {
"mfa_methods": "REQUIRED_FOR_ALL",
"allowed_mfa_methods": ["sms_otp", "totp"]
}
...
}
member_authenticated: false due to mfa_required — but no member_options are present. The Stytch SDK will automatically store the intermediate_session_token and handle passing that with subsequent calls to tie the user’s primary and secondary authentication together.For the primary authentication endpoint(s) you’ve already integrated add handling to detect when MFA enrollment is required by checking to see if the organization.mfa_methods is ALL_ALLOWED or RESTRICTED. If RESTRICTED use the organization.allowed_mfa_methods to surface only allowed MFA methods to the user.Check the organization.allowed_mfa_methods and based on the available options, prompt the user to enroll in MFA.Start MFA enrollment
- SMS OTP
- TOTP
For SMS MFA, prompt the user for their phone number and call the SMS Send method with the number. Make sure the
mfa_phone_number is in the E.164 format, e.g. “+14155551234”.Report incorrect code
Copy
Ask AI
import { useCallback, useState } from 'react';
import { useStytchB2BClient } from '@stytch/react/b2b';
export const SendOtp = () => {
const stytchClient = useStytchB2BClient();
const [phoneNumber, setPhoneNumber] = useState('');
const trigger = useCallback(() => {
stytchClient.otps.sms.send({
organization_id: 'organization-test-07971b06-ac8b-4cdb-9c15-63b17e653931',
member_id: 'member-test-32fc5024-9c09-4da3-bd2e-c2cfb6e1bd7d',
mfa_phone_number: phoneNumber,
});
}, [stytchClient, phoneNumber]);
return (
<div>
<input
type="tel"
placeholder="Enter phone number"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
/>
<button onClick={trigger}>Send OTP</button>
</div>
);
};
For TOTP MFA, you’ll first trigger the TOTP Create method, which will return a
qr_code that you will surface to the end user to register their authenticator app.Report incorrect code
Copy
Ask AI
import { useCallback } from 'react';
import { useStytchB2BClient } from '@stytch/react/b2b';
export const CreateTOTP = () => {
const stytchClient = useStytchB2BClient();
const trigger = useCallback(() => {
stytchClient.totp.create({ expiration_minutes: 60 });
}, [stytchClient]);
return <button onClick={trigger}>Create TOTP</button>;
};
Authenticate to complete enrollment
Prompt the user to input their OTP or TOTP code in order to complete the MFA enrollment and authenticate.
- SMS OTP
- TOTP
Report incorrect code
Copy
Ask AI
import { useCallback, useState } from 'react';
import { useStytchB2BClient } from '@stytch/react/b2b';
export const Authenticate = () => {
const stytch = useStytchB2BClient();
const [code, setCode] = useState('');
const authenticate = useCallback(
(e) => {
e.preventDefault();
stytch.otps.sms.authenticate({
member_id: 'member-test-32fc5024-9c09-4da3-bd2e-c9ce4da9375f',
organization_id: 'organization-test-07971b06-ac8b-4cdb-9c15-63b17e653931',
code: code,
session_duration_minutes: 60,
});
},
[stytch, code],
);
const handleChange = useCallback((e) => {
setCode(e.target.value);
}, []);
return (
<form>
<label htmlFor="otp-input">Enter code</label>
<input id="otp-input" autoComplete="one-time-code" inputMode="numeric" pattern="[0-9]*" onChange={handleChange} />
<button onClick={authenticate} type="submit">
Submit
</button>
</form>
);
};
Report incorrect code
Copy
Ask AI
import { useCallback, useState } from 'react';
import { useStytchB2BClient } from '@stytch/react/b2b';
export const Authenticate = () => {
const stytchClient = useStytchB2BClient();
const [totpCode, setTotpCode] = useState('');
const authenticate = useCallback(
(e) => {
e.preventDefault();
stytchClient.totp.authenticate({ code: totpCode, session_duration_minutes: 60 });
},
[stytchClient, totpCode],
);
const handleChange = useCallback((e) => {
setTotpCode(e.target.value);
}, []);
return (
<form>
<label htmlFor="totp-input">Enter code</label>
<input id="totp-input" value={totpCode} onChange={handleChange} />
<button onClick={authenticate} type="submit">
Submit
</button>
</form>
);
};
Returning MFA authentication
The flow for returning MFA authentication with a headless frontend integration is quite similar to the last steps of enrolling a Member in MFA for the first time.Detect user's MFA options
When a Member is already enrolled in MFA, the response from the various If a Member’s
authenticate() methods discussed in the “Required MFA Enrollment” section will include details on the MFA options they are enrolled in:Report incorrect code
Copy
Ask AI
{
"intermediate_session_token": "oNJB3foIA79dn_uNVMNghG_MGkKSLHnR65NsKXv0gZzY",
"mfa_required": {
"member_options": {
"mfa_phone": "+14151112222",
"totp_registration_id": "member-totp-test-41920359-8bbb-4fe8-8fa3-aaa83f35f02c"
},
"secondary_auth_initiated": "sms_otp"
},
"member_authenticated": false,
...
}
default_mfa_method is SMS OTP, Stytch will automatically trigger the SMS Send when the user performs primary authentication for that Organization and will indicate this in the secondary_auth_initiated field.Check the member_options and surface the appropriate input to the Member to complete MFA.Authenticate MFA
Similar to the last step of MFA enrollment, handle submission of the TOTP or OTP code in order to finish the MFA authentication and log the member in based on their default MFA method.
- SMS OTP
- TOTP
Report incorrect code
Copy
Ask AI
import { useCallback, useState } from 'react';
import { useStytchB2BClient } from '@stytch/react/b2b';
export const Authenticate = () => {
const stytch = useStytchB2BClient();
const [code, setCode] = useState('');
const authenticate = useCallback(
(e) => {
e.preventDefault();
stytch.otps.sms.authenticate({
member_id: 'member-test-32fc5024-9c09-4da3-bd2e-c9ce4da9375f',
organization_id: 'organization-test-07971b06-ac8b-4cdb-9c15-63b17e653931',
code: code,
session_duration_minutes: 60,
});
},
[stytch, code],
);
const handleChange = useCallback((e) => {
setCode(e.target.value);
}, []);
return (
<form>
<label htmlFor="otp-input">Enter code</label>
<input id="otp-input" autoComplete="one-time-code" inputMode="numeric" pattern="[0-9]*" onChange={handleChange} />
<button onClick={authenticate} type="submit">
Submit
</button>
</form>
);
};
Report incorrect code
Copy
Ask AI
import { useCallback, useState } from 'react';
import { useStytchB2BClient } from '@stytch/react/b2b';
export const Authenticate = () => {
const stytchClient = useStytchB2BClient();
const [totpCode, setTotpCode] = useState('');
const authenticate = useCallback(
(e) => {
e.preventDefault();
stytchClient.totp.authenticate({ code: totpCode, session_duration_minutes: 60 });
},
[stytchClient, totpCode],
);
const handleChange = useCallback((e) => {
setTotpCode(e.target.value);
}, []);
return (
<form>
<label htmlFor="totp-input">Enter code</label>
<input id="totp-input" value={totpCode} onChange={handleChange} />
<button onClick={authenticate} type="submit">
Submit
</button>
</form>
);
};
Optional MFA enrollment
If an Organization’s MFA policy isOPTIONAL, members can still opt to secure their account with MFA. At a high level, the flow for this is very similar to required enrollment, but can occur anytime after the Member has logged in and has been granted a Session.The headless SDK will take care of including the session_jwt / session_token in the authenticate() calls, so the only additional piece you’d need to do is to pass in the set_mfa_enrollment flag:- SMS OTP
- TOTP
Report incorrect code
Copy
Ask AI
stytch.otps.sms.authenticate({
member_id: 'member-test-32fc5024-9c09-4da3-bd2e-c9ce4da9375f',
organization_id: 'organization-test-07971b06-ac8b-4cdb-9c15-63b17e653931',
code: otpCode,
session_duration_minutes: 60,
set_mfa_enrollment: 'enroll'
});
Report incorrect code
Copy
Ask AI
stytch.totp.authenticate({
member_id: 'member-test-32fc5024-9c09-4da3-bd2e-c9ce4da9375f',
organization_id: 'organization-test-07971b06-ac8b-4cdb-9c15-63b17e653931',
code: totpCode,
session_duration_minutes: 60,
set_mfa_enrollment: 'enroll'
});
set_default_mfa boolean.If you want to enroll the user in MFA for occasional step-up authentication on particularly sensitive actions, rather than MFA that is required on each login you can omit the set_mfa_enrollment field in the authenticate() call.- First-time enrollment
- Returning login
- Optional enrollment
Required MFA enrollment
When an Organization’s MFA policy isREQUIRED_FOR_ALL but the Member is not currently enrolled in MFA, you’ll need to handle MFA enrollment after primary authentication.Detect when MFA enrollment required
The following APIs will return a Member Session if the Member is not enrolled in MFA OR an Indicating that Your frontend should prompt the user to enroll in MFA via the allowed methods.For TOTP, no user input is required to start registration. For SMS, your frontend will need to collect the user’s phone number in E.164 format (e.g., “+14155551234”).
intermediate_session_token and an mfa_required object that indicates MFA is required in order to be granted a Session for the Organization:- Authenticate SSO
- Authenticate OAuth
- Authenticate Magic Link
- Authenticate Password
- Exchange Session
- Password Reset by Email
- Password Reset by Existing Password
Report incorrect code
Copy
Ask AI
{
"intermediate_session_token": "oNJB3foIA79dn_uNVMNghG_MGkKSLHnR65NsKXv0gZzY",
"mfa_required": {
"member_options": null,
"secondary_auth_initiated": null
},
"member_authenticated": false,
"organization": {
"mfa_methods": "REQUIRED_FOR_ALL",
"allowed_mfa_methods": ["sms_otp", "totp"]
}
}
member_authenticated: false due to mfa_required — but no member_options are present.For the primary authentication endpoint(s) you’ve already integrated add handling to detect when MFA enrollment is required by checking to see if the organization.mfa_methods is ALL_ALLOWED or RESTRICTED. If RESTRICTED use the organization.allowed_mfa_methods to surface only allowed MFA methods to the user.Report incorrect code
Copy
Ask AI
@app.route("/authenticate", methods=["POST"])
def authenticate():
# After primary authentication (e.g., magic link, password, OAuth)
resp = stytch_client.magic_links.authenticate(
magic_links_token=request.form.get("token")
)
if resp.member_authenticated:
# No MFA required, create session
session["stytch_session_jwt"] = resp.session_jwt
return redirect(url_for("dashboard"))
# MFA is required - store intermediate session token
session["intermediate_session_token"] = resp.intermediate_session_token
# Check allowed MFA methods
allowed_methods = resp.organization.allowed_mfa_methods
if resp.organization.mfa_methods == "RESTRICTED":
# Only show allowed methods to user
return render_template("mfa_enrollment.html", allowed_methods=allowed_methods)
return render_template("mfa_enrollment.html", allowed_methods=["sms_otp", "totp"])
Start MFA enrollment
Add routes to handle starting the MFA enrollment process for SMS OTP or TOTP, depending on the users’ selection.
- SMS OTP
- TOTP
Report incorrect code
Copy
Ask AI
@app.route("/mfa/sms/send", methods=["POST"])
def mfa_sms_send():
intermediate_session_token = session.get("intermediate_session_token")
if not intermediate_session_token:
return "No intermediate session", 400
phone_number = request.form.get("phone_number")
resp = stytch_client.otps.sms.send(
organization_id=session.get("organization_id"),
member_id=session.get("member_id"),
mfa_phone_number=phone_number,
intermediate_session_token=intermediate_session_token,
)
if resp.status_code != 200:
return "Error sending OTP", 500
return render_template("mfa_verify.html", mfa_type="sms")
Report incorrect code
Copy
Ask AI
@app.route("/mfa/totp/create", methods=["POST"])
def mfa_totp_create():
intermediate_session_token = session.get("intermediate_session_token")
if not intermediate_session_token:
return "No intermediate session", 400
resp = stytch_client.totps.create(
organization_id=session.get("organization_id"),
member_id=session.get("member_id"),
intermediate_session_token=intermediate_session_token,
expiration_minutes=60,
)
if resp.status_code != 200:
return "Error creating TOTP", 500
# Return the QR code for the user to scan
return render_template(
"mfa_totp_setup.html",
qr_code=resp.qr_code,
secret=resp.secret,
)
Authenticate to complete enrollment
You’ll want to add a route that allows the user to finish the enrollment process by authenticating with their chosen factor.The member is now enrolled in MFA, and will be prompted to perform MFA through their selected method on subsequent authentication.
- SMS OTP
- TOTP
Your frontend should prompt the user for the 6-digit code they received via SMS and submit it along with:
organization_id: The Organization’s IDmember_id: The Member’s IDcode: The OTP code entered by the userintermediate_session_token: The token from primary authentication
Report incorrect code
Copy
Ask AI
@app.route("/mfa/sms/authenticate", methods=["POST"])
def mfa_sms_authenticate():
intermediate_session_token = session.get("intermediate_session_token")
if not intermediate_session_token:
return "No intermediate session", 400
code = request.form.get("code")
resp = stytch_client.otps.sms.authenticate(
organization_id=session.get("organization_id"),
member_id=session.get("member_id"),
code=code,
intermediate_session_token=intermediate_session_token,
session_duration_minutes=60,
)
if resp.status_code != 200:
return "Error authenticating OTP", 500
# Clear intermediate session and set authenticated session
session.pop("intermediate_session_token", None)
session["stytch_session_jwt"] = resp.session_jwt
return redirect(url_for("dashboard"))
For TOTP, you’ll need to first surface the QR Code so the user can create a TOTP registration in their authenticator app. Once registered, your UI should prompt them to input the TOTP code from their authenticator app. Your frontend should submit:
organization_id: The Organization’s IDmember_id: The Member’s IDcode: The 6-digit TOTP code from their authenticator appintermediate_session_token: The token from primary authentication
Report incorrect code
Copy
Ask AI
@app.route("/mfa/totp/authenticate", methods=["POST"])
def mfa_totp_authenticate():
intermediate_session_token = session.get("intermediate_session_token")
if not intermediate_session_token:
return "No intermediate session", 400
code = request.form.get("code")
resp = stytch_client.totps.authenticate(
organization_id=session.get("organization_id"),
member_id=session.get("member_id"),
code=code,
intermediate_session_token=intermediate_session_token,
session_duration_minutes=60,
)
if resp.status_code != 200:
return "Error authenticating TOTP", 500
# Clear intermediate session and set authenticated session
session.pop("intermediate_session_token", None)
session["stytch_session_jwt"] = resp.session_jwt
return redirect(url_for("dashboard"))
Returning MFA authentication
The flow for returning MFA authentication with a backend integration is quite similar to the last step of enrolling a Member in MFA for the first time.Detect user's MFA options
When a Member is already enrolled in MFA, the response from the various If a Member’s
authenticate() APIs discussed in the “Required MFA Enrollment” section will indicate this in the mfa_required.member_options object:Report incorrect code
Copy
Ask AI
{
"intermediate_session_token": "oNJB3foIA79dn_uNVMNghG_MGkKSLHnR65NsKXv0gZzY",
"mfa_required": {
"member_options": {
"mfa_phone": "+14151112222",
"totp_registration_id": "member-totp-test-41920359-8bbb-4fe8-8fa3-aaa83f35f02c"
},
"secondary_auth_initiated": "sms_otp"
},
"member_authenticated": false
}
default_mfa_method is SMS OTP, Stytch will automatically trigger the SMS Send when the user performs primary authentication for that Organization and will indicate this in the secondary_auth_initiated field.Check the member_options and surface the appropriate input to the Member to complete MFA:Report incorrect code
Copy
Ask AI
@app.route("/authenticate", methods=["POST"])
def authenticate():
# After primary authentication
resp = stytch_client.magic_links.authenticate(
magic_links_token=request.form.get("token")
)
if resp.member_authenticated:
session["stytch_session_jwt"] = resp.session_jwt
return redirect(url_for("dashboard"))
# MFA required - check member's enrolled methods
session["intermediate_session_token"] = resp.intermediate_session_token
member_options = resp.mfa_required.member_options
if member_options:
# Member is already enrolled in MFA
mfa_methods = []
if member_options.mfa_phone:
mfa_methods.append("sms_otp")
if member_options.totp_registration_id:
mfa_methods.append("totp")
# Check if SMS was auto-triggered
sms_auto_sent = resp.mfa_required.secondary_auth_initiated == "sms_otp"
return render_template(
"mfa_verify.html",
mfa_methods=mfa_methods,
sms_auto_sent=sms_auto_sent,
masked_phone=member_options.mfa_phone,
)
# Member needs to enroll in MFA
return render_template("mfa_enrollment.html")
- SMS OTP
- TOTP
Your frontend should prompt the user to input the OTP they received and submit it with:
organization_id: The Organization’s IDmember_id: The Member’s IDcode: The 6-digit OTP codeintermediate_session_token: The token from primary authentication
Your frontend should prompt the user to input their TOTP code from their authenticator app and submit it with:
organization_id: The Organization’s IDmember_id: The Member’s IDcode: The 6-digit TOTP codeintermediate_session_token: The token from primary authentication
Authenticate MFA
Similar to the last step of MFA enrollment, handle submission of the TOTP or OTP code in order to finish the MFA authentication and log the member in based on their default MFA method.
- SMS OTP
- TOTP
Report incorrect code
Copy
Ask AI
@app.route("/mfa/sms/authenticate", methods=["POST"])
def mfa_sms_authenticate():
intermediate_session_token = session.get("intermediate_session_token")
if not intermediate_session_token:
return "No intermediate session", 400
code = request.form.get("code")
resp = stytch_client.otps.sms.authenticate(
organization_id=session.get("organization_id"),
member_id=session.get("member_id"),
code=code,
intermediate_session_token=intermediate_session_token,
session_duration_minutes=60,
)
if resp.status_code != 200:
return "Error authenticating OTP", 500
session.pop("intermediate_session_token", None)
session["stytch_session_jwt"] = resp.session_jwt
return redirect(url_for("dashboard"))
Report incorrect code
Copy
Ask AI
@app.route("/mfa/totp/authenticate", methods=["POST"])
def mfa_totp_authenticate():
intermediate_session_token = session.get("intermediate_session_token")
if not intermediate_session_token:
return "No intermediate session", 400
code = request.form.get("code")
resp = stytch_client.totps.authenticate(
organization_id=session.get("organization_id"),
member_id=session.get("member_id"),
code=code,
intermediate_session_token=intermediate_session_token,
session_duration_minutes=60,
)
if resp.status_code != 200:
return "Error authenticating TOTP", 500
session.pop("intermediate_session_token", None)
session["stytch_session_jwt"] = resp.session_jwt
return redirect(url_for("dashboard"))
Optional MFA enrollment
If an Organization’s MFA policy isOPTIONAL, members can still opt to secure their account with MFA. At a high level, the flow for this is very similar to required enrollment, but can occur anytime after the Member has logged in and has been granted a Session.Add a route for optional enrollment that expects a session_jwt or session_token before calling the MFA Authentication endpoint that finalizes the enrollment. This can be used to add optional MFA to users or additional MFA methods to users who want to use both TOTP and SMS.- SMS OTP
- TOTP
Report incorrect code
Copy
Ask AI
@app.route("/optional-mfa-enrollment", methods=["POST"])
def optional_mfa_enrollment():
jwt = session.get("stytch_session_jwt")
if not jwt:
return "No session", 400
resp = stytch_client.sessions.authenticate_jwt(session_jwt=jwt)
if resp.status_code != 200:
return "Invalid session", 401
organization_id = resp.member_session.organization_id
member_id = resp.member_session.member_id
code = request.form.get("code")
resp = stytch_client.otps.sms.authenticate(
session_jwt=jwt,
code=code,
organization_id=organization_id,
member_id=member_id,
set_mfa_enrollment="enroll",
)
if resp.status_code != 200:
return "Error authenticating MFA", 500
session["stytch_session_jwt"] = resp.session_jwt
return redirect(url_for("dashboard"))
Report incorrect code
Copy
Ask AI
@app.route("/optional-mfa-enrollment", methods=["POST"])
def optional_mfa_enrollment():
jwt = session.get("stytch_session_jwt")
if not jwt:
return "No session", 400
resp = stytch_client.sessions.authenticate_jwt(session_jwt=jwt)
if resp.status_code != 200:
return "Invalid session", 401
organization_id = resp.member_session.organization_id
member_id = resp.member_session.member_id
code = request.form.get("code")
resp = stytch_client.totps.authenticate(
session_jwt=jwt,
code=code,
organization_id=organization_id,
member_id=member_id,
set_mfa_enrollment="enroll",
)
if resp.status_code != 200:
return "Error authenticating MFA", 500
session["stytch_session_jwt"] = resp.session_jwt
return redirect(url_for("dashboard"))
set_default_mfa boolean.If you want to enroll the user in MFA for occasional step-up authentication on particularly sensitive actions, rather than MFA that is required on each login you can omit the set_mfa_enrollment field in the authenticate() call.