/
Contact usSee pricingStart building

    About Stytch Fraud and Risk

    Introduction
    Use Cases
      Overview
    • Recipes

      • Remembered device flow
    Device Fingerprinting
      Overview
      Fingerprints
    • Verdicts

      • Verdicts overview
        Allow
        Block
        Challenge
        Not Found
    Getting started
      Device Fingerprinting API
      DFP Protected Auth
    Decisioning
      Decisioning overview
      Setting rules with DFP
      Overriding verdict reasons
      Intelligent Rate Limiting
    Enforcement
      Enforcement overview
    • Protected Auth

      • Overview
        Handling challenges
    Integration steps
      Configure custom domains
      Test your integration
      Privacy and compliance considerations
    Reference
      Warning Flags (Verdict Reasons)
Get support on SlackVisit our developer forum

Contact us

Fraud and Risk Prevention

/

Guides

/

About Stytch Fraud and Risk

/

Use Cases

/

Recipes

/

Remembered device flow

Remembered Device Flow

You can see a hosted demo of this flow at our demo application here, by selecting the “Remembered Device flow” recipe card! The relevant code is in GitHub here.

In this guide we’ll walk through how to leverage Stytch’s Device Fingerprinting (DFP) product to implement a remembered device flow. Remembered device flows are a form of adaptive MFA, or triggering MFA only on unrecognized logins.

For this guide, we’ll use the Visitor ID as the known device identifier. Visitor ID is a cookie-based identifier, and is a good choice for allowlists because good actors don’t often intentionally clear their cookies, though it can happen naturally if they use a different browser for example. Other good examples for trigger points are login attempts from a new IP address or a new country.

We’ll be using a Stytch Consumer project in a NextJS application for the purposes of this guide, but the same basic steps apply to any auth implementation.

You will:

  1. Generate device fingerprints and signals from the frontend and submit them as part of the authentication request.
  2. Evalutate whether this is a recognized device or not using the Stytch Device Fingerprinting Lookup response and Stytch User metadata.
  3. Conditionally show super-secret data within the application if either:
    • The login request is from a known device, or
    • Step-up MFA is completed when the login request is not from a known device.
  4. Store this context in Session custom claims via a claim called authorized_for_secret_data for future reference throughout your application, and update the Known Devices list.

Before you start

In order to complete this guide, you’ll need:

  1. A Stytch project (either Consumer or B2B).
    • If you don't have one already, in the Dashboard, click on your existing project name in the top left corner of the Dashboard, click Create Project, and then select B2B Authentication or Consumer Authentication.
  2. Access to our Device Fingerprinting product if you don't have it already.
  3. A basic authentication flow on which to add adaptive MFA.
    • If you don’t have one set up already, you can utilize one of our example apps. The snippets in this guide will follow a recipe from our nextJS demo application (code here - look for "Remembered Device" files).
    • This guide uses Stytch for authentication, but any backend authentication implementation can work.

1
Generating signals

The first step of this guide is step 1 and 2 of the Getting Started guide here.

Add the submit script to your login flow and a function:

<head>
    <script src="https://elements.stytch.com/telemetry.js"></script>
</head>

And a method or event handler to call the async function GetTelemetryID(), which gathers signals from the device and resolves to a telemetry_id.

const authenticateWithTelemetry = async () => {

// Get telemetry_id from the telemetry script
let telemetryId: string | undefined;
      
try {
  const publicToken = process.env.NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN;
  telemetryId = await (window as any).GetTelemetryID({ publicToken });
} catch (telemetryError) {
  console.warn('Could not get telemetry ID:', telemetryError);
}

When you call your backend authenticate endpoint, send the telemetry_id along as well.

In the snippet below from the example app flow, we’re utilizing Email Magic Links as our authentication method, so the telemetry_id is generated and sent along in the request to authenticate the Magic Link token.

// Call our authenticate API
const response = await fetch('/api/authenticate_eml_remembered_device', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    ...(telemetryId && { 'X-Telemetry-ID': telemetryId }),
  },
  body: JSON.stringify({ token }),
});

2
Authentication and Remembered Device Evaluation

Your backend endpoint will

  1. Authenticate the Email Magic Link token.
  2. Make the DFP /lookup call to determine the Visitor ID.
  3. Compare this against the known devices list to determine whether this is a known device or not.
  4. Store the Remembered Device context in session.custom_claims for reference downstream.
// First, authenticate the magic link token
let authenticateResponse = await stytchClient.magicLinks.authenticate({
  token: token,
  session_duration_minutes: 10080,
});

Get the list of already known devices:

let knownDevices = authenticateResponse.user.trusted_metadata?.known_devices || [];

Perform the /lookup call to check the Visitor ID for this login request:

// Fail closed: require MFA if no telemetry ID provided
if (!telemetryId) {
  console.log('No telemetry ID provided, requiring MFA (fail closed)');
  requiresMfa = true;
} else {
  try {
    // Lookup the telemetry ID response to get the Visitor ID
    const fingerprintResponse = await stytchClient.fraud.fingerprint.lookup({
      telemetry_id: telemetryId,
    });

  // Logic for decisioning is in the following snippets

  } catch (err) {
    console.error('Error checking remembered device:', telemetryError);
    // Fail closed: if telemetry lookup fails, require MFA for security
    requiresMfa = true;
  }
}

In the block where you successfully get a lookup response, determine whether MFA is required based on whether the request is from a known device:

  • If known:
    • Set authorized_for_secret_data: true in the Session claims
    • Return super secret data already, or optionally return it elsewhere in your application, gated on session.custom_claims.authorized_for_secret_data: true
  • If not known:
    • Store the pending Visitor ID in session custom claims for reference later on (to add to the known devices list in the case of a successful MFA flow),
    • Set authorized_for_secret_data: false in the Session claims.
    • Let the frontend know that MFA is required
// Function to determine if a device is known
function isKnownDevice(visitorID: string, knownDevices: string[]) {
  return knownDevices.includes(visitorID);
}

// Default to closed
let requiresMfa = true;

    ... // Inside the try/catch from above:

    // Check if the Visitor ID is in the known devices list
    if (isKnownDevice(visitorID, knownDevices) && visitorID !== '') {
      console.log('Visitor ID is known, no MFA required');
      requiresMfa = false;
      // Update session with custom claims to mark this session as authorized
      await stytchClient.sessions.authenticate({
        session_token: authenticateResponse.session_token,
        session_custom_claims: {
          authorized_for_secret_data: true,
          authorized_device: visitorID,
        },
      });
    } else {
      console.log('Visitor ID is not known, MFA required');
      requiresMfa = true;
      // Store pending visitorID in session custom claims for later retrieval during OTP auth
      await stytchClient.sessions.authenticate({
        session_token: authenticateResponse.session_token,
        session_custom_claims: {
          authorized_for_secret_data: false,
          pending_device: visitorID,
        },
      });
    }

Finally, return a response to your frontend with the Session. In this example, we go ahead and include the super secret data in the response if MFA isn’t required.

// Set the session cookie in the response
res.setHeader('Set-Cookie', `api_sms_remembered_device_session=${authenticateResponse.session_token}; Path=/; Max-Age=1800; SameSite=Lax`);


return res.status(200).json({
  session_token: authenticateResponse.session_token,
  visitorID: visitorID,
  user_id: authenticateResponse.user_id,
  super_secret_data: !requiresMfa ? "SUPER SECRET DATA HERE" : undefined,
});

3
Conditional access and MFA enforcement

Trusted Device → Grant Access

Untrusted Device → Prompt MFA

Now your application enters the Conditional Access & MFA Enforcement step: Trusted devices receive access to the protected data; unrecognized devices must complete MFA before access is granted.

In our example application recipe, we store that logic in getServerSideProps - notably, all of the variables used in the logic tree here cannot be changed or accessed by a user:

export const getServerSideProps: GetServerSideProps = async (context) => {
  const cookies = new Cookies(context.req, context.res);
  const storedSession = cookies.get('api_sms_remembered_device_session');
  
  ...

     // Get the session data
    const { session } = await stytchClient.sessions.authenticate({ session_token: storedSession });

    let superSecretData = null;
    let isRememberedDevice = false;
    let requiresMfa = true; // Default to requiring MFA unless session proves otherwise
    let visitorID = '';

    // Server-side authorization check based on session authentication factors 
    const hasEmailFactor = session.authentication_factors.find((i: any) => i.delivery_method === 'email');
    const hasSmsFactor = session.authentication_factors.find((i: any) => i.delivery_method === 'sms');
    
    if (hasEmailFactor && hasSmsFactor) {
      // User has completed full MFA - authorized for super secret data
      superSecretData = SUPER_SECRET_DATA.FULL_MFA;
      requiresMfa = false;
    } else if (hasEmailFactor && session.custom_claims?.authorized_for_secret_data) {
      // User is in a remembered device location (authorized during EML auth via session claims)
      superSecretData = SUPER_SECRET_DATA.REMEMBERED_DEVICE;
      isRememberedDevice = true;
      requiresMfa = false;
      visitorID = session.custom_claims.authorized_device as string || '';
    } else {
      // User needs MFA - either no email factor or not in trusted location
      requiresMfa = true;
      visitorID = session.custom_claims?.pending_device as string || '';
    }

   return {
      props: {
        user: JSON.parse(JSON.stringify(user)),
        session: JSON.parse(JSON.stringify(session)),
        ...
        superSecretData,
        isRememberedDevice,
        requiresMfa,
        visitorID,
      },
}

And use this data to determine frontend behavior; for example, the React component in our example app looks like:

<div style={styles.secretBox}>
  <h3>Super secret area</h3>
  {superSecretData ? (
  <div>
    <p>{superSecretData}</p>
    {isRememberedDevice && (
    <p style={styles.rememberedDeviceNote}>
      🎉 <strong>Device location remembered!</strong> You bypassed MFA because this device was recognized.
    </p>
    )}
  </div>
  ) : (
  <>
    <Image alt="Lock" src={lock} width={100} />
    <p>
      {requiresMfa
      ? `Additional authentication required. This appears to be a new device. Please
      complete SMS verification to continue.`
      : 'Super secret profile information is secured by two factor authentication. To unlock this area complete the SMS OTP flow.'
      }
    </p>
    {hasRegisteredPhone && phoneNumber ? (
    <SMSOTPButton phoneNumber={phoneNumber} />
    ) : (
    <SMSRegister />
    )}
  </>

4
Update Known Devices list

If a user logs in from a new Visitor ID and successfully completes MFA, add that new visitor ID to the known devices list so they can skip MFA in subsequent logins.

This should be done on your backend and require a Session with both factors (primary and MFA):

export async function handler(req: NextApiRequest, res: NextApiResponse<ErrorData | SuccessData>) {
  const stytchClient = loadStytch();

  // Get session from cookie
  const cookies = new Cookies(req, res);
  const storedSession = cookies.get('api_sms_remembered_device_session');

  // Authenticate the session to get user ID and verify factors
  const { session } = await stytchClient.sessions.authenticate({ session_token: storedSession });

  // Verify that the session has both EML and SMS factors (proving MFA completion)
  const hasEmailFactor = session.authentication_factors.some(f => f.delivery_method === 'email');
  const hasSmsFactor = session.authentication_factors.some(f => f.delivery_method === 'sms');

  if (!hasEmailFactor || !hasSmsFactor) {
    return res.status(403).json({ errorString: 'MFA not completed. Both email and SMS factors required.' });
  }

  // Get the pending device from session custom claims that was stored during EML authentication
  const pendingDevice = session.custom_claims?.pending_device;

  // Get user to access existing trusted metadata
  const user = await stytchClient.users.get({ user_id: session.user_id });

  // Get existing known devices or initialize empty array
  const existingKnownDevices = user.trusted_metadata?.known_devices || [];

  // Add new device if it's not already in the list
  if (!existingKnownDevices.includes(pendingDevice)) {
    const updatedKnownDevices = [...existingKnownDevices, pendingDevice];

    // Update the user's trusted metadata and clean up pending device
    await stytchClient.users.update({
      user_id: session.user_id,
      trusted_metadata: {
        ...user.trusted_metadata,
        known_devices: updatedKnownDevices,
        pending_device: undefined, // Remove the pending device
      },
    });

...
}

You will:

Before you start

1.

Generating signals

2.

Authentication and Remembered Device Evaluation

3.

Conditional access and MFA enforcement

4.

Update Known Devices list