B2B Saas Authentication

/

Guides

/

Authentication

/

Multi-Factor Authentication

/

Integration Guides

/

Backend integration

Backend Integration of MFA

This guide covers enrollment and authentication of MFA using an entirely backend approach, leveraging our backend SDKs or direct APIs.

Required MFA Enrollment

The below sequence outlines the expected flow after primary authentication, when an Organization's MFA Policy is REQUIRED_FOR_ALL but the Member is not currently enrolled in MFA.

Backend integration of required MFA enrollment

1
Detect when MFA enrollment required

The following APIs will return a Member Session if the Member is not enrolled in MFA OR an intermediate_session_token and an mfa_required object that indicates MFA is required in order to be granted a Session for the Organization:

If the Member is not yet enrolled in MFA, the response will look as follows:

{
	"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"]
    }
    ...
}

Indicating that 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.

def authenticate() -> str:
    # Any primary auth flow, EML shown for demonstration
    if request.args["stytch_token_type"] != "multi_tenant_magic_links":
        return "unsupported auth method", 400
    
    resp = stytch_client.magic_links.authenticate(magic_links_token=request.arg["token"])
    if resp != 200:
        return "something went wrong", 500

    if resp.member_authenticated:
        # MFA not required case, shown for demonstration
        session['stytch_session'] = resp.session_token
        return redirect(url_for('org_home', slug=resp.organization.organization_slug))
    
    # Store IST to include in MFA authentication
    session['ist'] = resp.intermediate_session_token

    # Member must enroll in MFA
    if resp.mfa_required.member_options is None:

        # Only surface options that are allowed for the Organization
        if resp.organization.mfa_methods == "ALL_ALLOWED:
            sms_allowed = True
            totp_allowed = True
        else:
            sms_allowed = ("sms_otp" in resp.organization.allowed_mfa_methods)
            totp_allowed = ("totp" in resp.organization.allowed_mfa_methods)
            

        return render_template(
            'enrollMFA.html',
            sms_allowed=sms_allowed,
            totp_allowed=totp_allowed,
            organization_id=resp.organization.organization_id,
            member_id=resp.member.member_id
        )

    # Case where member already enrolled covered in next guide
    return render_template(
        'submitOTP.html',
        organization_id=resp.organization.organization_id,
        member_id=resp.member.member_id
    )

Create a basic template that conditionally renders the MFA options to show to the user, and prompts them to enroll.

For TOTP no user input is required to start registration, but for SMS you'd want a form field for the user to input their phone number:

<form action="/enroll-sms" method="post">
    <label for="phone">Phone Number:</label>
    <input type="text" id="phone" name="phone" required>
    <input type="hidden" name="organization_id" value="{{ organization_id }}">
    <input type="hidden" name="member_id" value="{{ member_id }}">
    <button type="submit">Submit</button>
</form>

2
Start MFA enrollment

Add routes to handle starting the MFA enrollment process for SMS OTP or TOTP, depending on the users' selection.

@app.route("/enroll-sms", methods=["POST"])
def enroll_sms() -> str:
    phone = request.form.get('phone', None)
    organization_id = request.form.get('organization_id', None)
    member_id = request.form.get('member_id', None)
    if phone is None or organization_id is None or member_id is None:
        return 'Missing required field', 400

    # Make sure the `mfa_phone_number` is in the E.164 format, e.g. “+14155551234”.
    resp = stytch_client.otps.sms.send(
        organization_id=organization_id,
        member_id=member_id,
        mfa_phone_number=phone
    )
    if resp.status_code != 200:
        return "Error sending SMS", 500
    
    return render_template(
        'inputMFACode.html',
        organization_id=resp.organization.organization_id,
        member_id=resp.member.member_id
    )

@app.route("/enroll-totp", methods=["POST"])
def enroll_totp() -> str:
    organization_id = request.form.get('organization_id', None)
    member_id = request.form.get('member_id', None)
    if organization_id is None or member_id is None:
        return 'Missing required field', 400

    resp = stytch_client.totps.create(
        organization_id=organization_id,
        member_id=member_id
    )
    if resp.status_code != 200:
        return "Error sending EML", 500
     
    return render_template(
        'registerTOTP.html',
        qr_code=resp.qr_code,
        organization_id=resp.organization.organization_id,
        member_id=resp.member.member_id
    )

3
Authenticate to complete enrollment

You'll want to add a template that allows the user to finish the enrollment process by authenticating with their chosen factor.

For SMS this would be a form that allows the user to input the OTP they received like:

<form action="/authenticate-mfa" method="post">
    <label for="code">Input Code:</label>
    <input type="code" id="code" name="code" required>
    <input type="hidden" id="type" name="type" value="sms">
    <input type="hidden" name="organization_id" value="{{ organization_id }}">
    <input type="hidden" name="member_id" value="{{ member_id }}">
    <button type="submit">Submit</button>
</form>

For TOTP, you'll need to first surface the QR Code so the user can create a TOTP registration in their authenticator app, and then input the TOTP code from that registration.

<body>
    <h1>Scan QR Code and once registered, input TOTP code from your authenticator app:</h1>
    <img src="{{qr_code}}" alt="QR Code">
    <div>
        <form action="/authenticate-mfa" method="post">
        <label for="code">Input Code:</label>
        <input type="code" id="code" name="code" required>
        <input type="hidden" id="type" name="type" value="totp">
        <button type="submit">Submit</button>
        </form>
    </div>
</body>

When the user submits their TOTP or OTP code, you'll call Stytch to authenticate the code. If successful, this will complete the Member's enrollment in MFA and grant them a Member Session.

@app.route("/authenticate-mfa", methods=["POST"])
def authenticate_mfa() -> str:
    code = request.form.get('code', None)
    organization_id = request.form.get('organization_id', None)
    member_id = request.form.get('member_id', None)
    mfa_type = request.form.get('type', None)
    if code is None or organization_id is None or member_id is None:
        return 'Missing required field', 400

    ist = session.get('ist')
    if not ist:
        return 'No intermediate session token', 400
    if mfa_type == 'sms':
        resp = stytch_client.otps.sms.authenticate(
            intermediate_session_token=ist,
            code=code,
            organization_id=organization_id,
            member=member_id
        )
    elif mfa_type == 'totp':
        resp = stytch_client.totps.authenticate(
            intermediate_session_token=ist,
            code=code,
            organization_id=organization_id,
            member=member_id
        )
    else:
        return 'unsupported method', 400
    
    if resp.status_code != 200:
        return 'error authenticating mfa', 500
    
    session.pop('ist', None)
    session['stytch_session'] = resp.session_token
    return redirect(url_for('org_home', slug=resp.organization.organization_slug))

The member is now enrolled in MFA, and will be prompted to perform MFA through their selected method on subsequent authentication.

4
Test it out

Run your application and attempt to login to your Organization with the REQUIRED_FOR_ALL MFA Policy to test out required enrollment in MFA.

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.

Backend integration of returning MFA authentication

1
Detect user's MFA options

When a Member is already enrolled in MFA, the response from the various authenticate() APIs discussed in the "Required MFA Enrollment" section will include indicate this in the mfa_required.mfa_options object:

{
	"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,
    ...
}

If a Member's 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:

def authenticate() -> str:

    # Any primary auth flow, EML shown for demonstration
    if request.args["stytch_token_type"] != "multi_tenant_magic_links":
        return "unsupported auth method", 400
    
    resp = stytch_client.magic_links.authenticate(magic_links_token=request.arg["token"])
    if resp != 200:
        return "something went wrong", 500

    if resp.member_authenticated:
        # MFA not required case, shown for demonstration
        session['stytch_session'] = resp.session_token
        return redirect(url_for('org_home', slug=resp.organization.organization_slug))
    
    # Store IST to include in MFA enrollment
    session['ist'] = resp.intermediate_session_token

    if resp.mfa_required.member_options is None:
        # Enrollment flow, covered in previous guide
        return render_template('enrollMFA.html')

    code_type = 'totp'
    if resp.mfa_required.member_options.secondary_auth_initiated == 'sms_otp':
        code_type = 'sms'
    
    return render_template(
        'inputMFACode.html',
        organization_id=resp.organization.organization_id,
        member_id=resp.member.member_id,
        code_type=code_type
    )

Surface a template that allows the user to input their SMS or TOTP code

<div>
    <form action="/authenticate-mfa" method="post">
        <label for="code">Input {{ code_type }} Code:</label>
        <input type="text" id="code" name="code" required>
        <input type="hidden" id="type" name="type" value="{{ code_type }}">
        <input type="hidden" name="organization_id" value="{{ organization_id }}">
        <input type="hidden" name="member_id" value="{{ member_id }}">
        <button type="submit">Submit</button>
    </form>
</div>

2
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.

@app.route("/authenticate-mfa", methods=["POST"])
def authenticate_mfa() -> str:

    organization_id = request.form.get('organization_id', None)
    member_id = request.form.get('member_id', None)

    code = request.form.get('code', None)
    mfa_type = request.form.get('type', None)
    if code is None or organization_id is None or member_id is None:
        return 'Missing required field', 400

    ist = session.get('ist')
    if not ist:
        return 'No intermediate session token', 400

    if mfa_type == 'sms':
        resp = stytch_client.otps.sms.authenticate(
            intermediate_session_token=ist,
            code=code,
            organization_id=organization_id,
            member=member_id
        )
    elif mfa_type == 'totp':
        resp = stytch_client.totps.authenticate(
            intermediate_session_token=ist,
            code=code,
            organization_id=organization_id,
            member=member_id
        )
    else:
        return 'unsupported method', 400
    
    if resp.status_code != 200:
        return 'error authenticating mfa', 500
    
    session.pop('ist', None)
    session['stytch_session'] = resp.session_token
    return redirect(url_for('org_home', slug=resp.organization.organization_slug))

3
Test it out

Run your application and go through an authentication flow with a user who already has an MFA method enabled.

Optional MFA Enrollment

If an Organization's MFA Policy is OPTIONAL 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.

Backend integration of optional MFA enrollment

1
Add optional enrollment

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.

@app.route("/optional-mfa-enrollment", methods=["POST"])
def optional_mfa_enrollment() -> str:

    jwt = session.get('stytch_session_jwt')
    if not jwt:
        return 'No session', 400

    resp = stytch_client.sessions.authenticateJwt(session_jwt=jwt)
    if resp.status_code != 200:
        return 'Invalid session', 401
    
    organization_id = resp.organization_id
    member_id = resp.member_id

    code = request.form.get('code', None)
    mfa_type = request.form.get('type', None)

    if mfa_type == 'sms':
        resp = stytch_client.otps.sms.authenticate(
            session_jwt=jwt,
            code=code,
            organization_id=organization_id,
            member=member_id,
            set_mfa_enrollment='enroll'
        )
    elif mfa_type == 'totp':
        resp = stytch_client.totps.authenticate(
            session_jwt=jwt,
            code=code,
            organization_id=organization_id,
            member=member_id,
            set_mfa_enrollment='enroll'
        )
    else:
        return 'unsupported method', 400
    
    if resp.status_code != 200:
        return 'error authenticating mfa', 500
    
    session['stytch_session_jwt'] = resp.session_jwt
    return redirect(url_for('org_home', slug=resp.organization.organization_slug))

If the Member is enrolling in an additional form of MFA (e.g. has SMS MFA already, and is adding TOTP) you can also specify that the newly enrolled MFA method becomes the default through the set_default_mfa boolean.

If you want to enroll the user in MFA for occassional 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.

2
Test it out

Run your app, login to an Organization with an MFA Policy of OPTIONAL and request to add an MFA method.