Guides and TutorialsMFA and Step-UpMFA (Multi-factor Authentication)

Homegrown Auth with MFA

If you already have your own login system, with your own password checks and your own sessions, you don't need to rebuild it to add MFA. Your backend keeps validating the primary factor and issuing its own JWTs. Descope only handles the second factor, then hands control back to you.

Note

If you'd rather have Descope fully manage sessions (and get Descope JWTs back directly), see MFA with Backend SDKs or MFA with Client SDKs instead. This guide is for teams that want to keep their existing auth system as the source of truth.

How It Works

This pattern uses Descope as an OAuth provider for the second factor only:

  1. The user submits credentials to your backend as they always have.
  2. Your backend validates the primary factor however it already does (password, existing session, etc.).
  3. Your backend redirects the user to a Descope-hosted flow to complete MFA, using the Descope SDK's OAuth helpers.
  4. The user completes the second factor of your choice (passkeys, TOTP, OTP, or magic link; see Second-Factor Options below).
  5. Descope redirects back to your callback with an authorization code.
  6. Your backend exchanges the code for a Descope session and validates it to confirm MFA succeeded.
  7. Your backend mints its own JWT and returns it to the user. Descope's session token only confirms MFA; your app doesn't use it going forward.

Prerequisites

  • A Descope project.
  • A custom OAuth provider named Descope configured in Authentication Methods → OAuth, pointing its authorization and token URLs at your own project's OIDC endpoints. This is what gives oauth.start('Descope', redirectUrl) somewhere to redirect to. See Configuring a Custom Provider and OIDC Endpoints.
  • A Descope flow behind that provider that runs the MFA challenge. This is where you drop in one of the flow templates below.
  • The Descope Node SDK (or any backend SDK) installed in your app.
Terminal
npm i --save @descope/node-sdk jose
.env
JWT_SECRET=your-own-jwt-signing-secret
DESCOPE_PROJECT_ID=your-project-id
DESCOPE_REDIRECT_URL=http://localhost:3000/api/auth/callback

Code Walkthrough

This example is based on the homegrown-auth-server sample app (Node/Express + TypeScript). The same pattern applies with any backend SDK.

1. Initialize the SDK and set up the app

src/config/descope.ts
import DescopeClient from '@descope/node-sdk';

const descopeClient = DescopeClient({
  projectId: process.env.DESCOPE_PROJECT_ID || '',
});

export default descopeClient;

The rest of this walkthrough lives in your Express entry point. Set up the pieces every later step builds on:

api/index.ts
import express, { Request, Response, NextFunction } from 'express';
import { SignJWT, jwtVerify } from 'jose';
import descopeClient from '../src/config/descope';

const app = express();
app.use(express.json());

// Extend Express's Request type so authenticated routes can read req.user
interface AuthenticatedRequest extends Request {
  user?: any;
}

2. Validate the primary credential

Keep your existing login logic exactly as it is. If the primary factor checks out, kick off the redirect to Descope for MFA instead of logging the user in directly.

api/index.ts
app.post('/api/auth/login', async (req: Request, res: Response) => {
  const { email, password } = req.body;

  if (isValidPassword(email, password)) {
    const redirectUrl = await getOidcRedirectUrl(email);
    res.json({ redirectUrl });
  } else {
    res.status(401).json({ message: 'Invalid credentials' });
  }
});

3. Redirect to Descope for the second factor

oauth.start builds the authorization URL for the custom Descope provider you configured. Its full signature is (provider, redirectUrl, loginOptions, token, loginHint). Pass the identity you verified in the previous step as loginHint so it ties the MFA step to the same user and pre-fills it in the hosted flow.

api/index.ts
const getOidcRedirectUrl = async (userEmail: string) => {
  const redirectUrl = process.env.DESCOPE_REDIRECT_URL || '';
  const response = await descopeClient.oauth.start(
    'Descope',
    redirectUrl,
    undefined,
    undefined,
    userEmail,
  );

  if (!response.ok || !response.data?.url) {
    throw new Error(`OAuth start failed: ${response.error?.errorMessage}`);
  }

  return response.data.url;
};

4. Handle the callback and exchange the code

Once the user completes MFA, Descope redirects back with a code. Exchange it for a session.

api/index.ts
app.get('/api/auth/callback', async (req: Request, res: Response) => {
  const { code, error } = req.query;

  if (error || !code || typeof code !== 'string') {
    return res.status(400).json({ message: `Authentication failed: ${error ?? 'missing code'}` });
  }

  const tokenResponse = await descopeClient.oauth.exchange(code);
  if (!tokenResponse.ok) {
    return res.status(401).json({ message: 'Failed to exchange authorization code' });
  }

  const sessionToken = tokenResponse.data?.refreshJwt;
  // continue to session validation below...
});

5. Validate the session, then mint your own JWT

validateSession throws if the session isn't valid, so wrap it in a try/catch rather than checking for a falsy return. On success it returns the decoded token; token.sub is the Descope user ID, which is what you sign into your own JWT.

api/index.ts
try {
  const { token } = await descopeClient.validateSession(sessionToken);

  const secret = new TextEncoder().encode(process.env.JWT_SECRET || '');
  const userToken = await new SignJWT({ sub: token.sub })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('1h')
    .sign(secret);

  // Return userToken to the client however your app normally establishes a session
  // (cookie, redirect with token, etc.)
} catch {
  return res.status(401).json({ message: 'Invalid session' });
}

Need more than the user ID in your own JWT, such as email? Read it from the user object on the oauth.exchange response in the previous step, or add it as a custom claim in the Descope flow.

6. Protect routes with your own JWT

From here on, your app doesn't need Descope at all. Verify your own JWT like you always have.

api/index.ts
const authenticateToken = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ message: 'No token provided' });

  try {
    const secret = new TextEncoder().encode(process.env.JWT_SECRET || '');
    const { payload } = await jwtVerify(token, secret);
    req.user = payload;
    next();
  } catch {
    res.status(401).json({ message: 'Invalid token' });
  }
};

app.get('/api/protected', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
  res.json({ message: 'This is a protected route', user: req.user });
});

Second-Factor Options

Swap in any of these flow templates as the flow behind your Descope custom provider:

Second factorFlow template
Magic LinkMagic Link Sign-Up-Or-In
One-Time Password (OTP)Email or Phone Sign-Up-Or-In
PasskeysBiometrics + OTP Sign-In
TOTP (Authenticator App)TOTP

Browse the full Flow Library for more options, or see Using the Flow Library for how to preview and customize a template.

Don't Ask for the Login ID Twice

Your backend already collected the user's email or phone in the primary login step. Don't make users type it again once they land in the Descope-hosted flow.

Note

login_hint pre-fills the form.externalId context key in the flow. It does not populate form.email or form.phone, the fields the templates above use, and it does not skip the identifier-collection screen on its own. See Dynamic Values for the full list of form context keys.

The templates above start with a "Collect Email or Phone" screen that writes into form.email or form.phone. To skip it when a login_hint is present, edit your copy of the template one of two ways:

  1. Skip it conditionally. Add a Scriptlet at the start of the flow that copies form.externalId into form.email (or form.phone, matching your identifier format). Then add a Condition that skips straight to the send-magic-link, send-OTP, or verify-TOTP step whenever that value is already set, and falls through to the "Collect Email or Phone" screen only when it's empty. Use this option if the flow might also run without a login_hint, such as when you test it directly in the console.
  2. Remove the screen entirely. If this flow only runs through your OAuth redirect, where login_hint is always set, delete the "Collect Email or Phone" screen from your copy of the template and feed the scriptlet's output directly into the send or verify action.

Try It Yourself

Find the full working example on GitHub:

Terminal
git clone https://github.com/descope-sample-apps/homegrown-auth-server.git
cd homegrown-auth-server
npm install
npm run dev

For a narrated walkthrough of this same sample app, see Adding MFA to Homegrown Auth With Descope on the Descope blog.

Was this helpful?

On this page