In this article
July 16, 2026
July 16, 2026

Adding MFA to your TanStack Start app

A complete guide to TOTP authentication and step-up verification in TanStack Start, so your most sensitive server functions stay protected even hours after sign-in.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Most MFA guides stop at the login screen. Enable the feature, scan the QR code, enter the six-digit code, done. That is the easy part.

The harder question is what to do after login. Should a user who authenticated 12 hours ago with MFA be allowed to delete their account? Transfer funds? Change their email address? MFA at login gives you a stronger initial authentication signal. It does not protect you from session hijacking, stolen cookies, or a compromised device that stays authenticated for days.

This guide covers MFA in TanStack Start from both angles: how to add it to the login flow, and how to enforce step-up authentication on specific server functions so that high-stakes operations require a fresh verification regardless of when the user last signed in.

What MFA actually protects against

MFA adds a second factor to authentication: something the user knows (password) plus something they have (an authenticator app). Even if an attacker has the user's password, they cannot complete login without the current TOTP code from the user's device.

What MFA does not protect against: a valid session that was stolen after a successful MFA login. If an attacker gets hold of a session cookie through XSS, a network interception, or physical access to a device, they inherit whatever the session allows. This is why step-up authentication exists: for the most sensitive operations, require a fresh MFA code at the point of the action, not just at the point of login.

The combination of MFA at login plus step-up authentication on high-risk server functions covers both attack surfaces.

Why not SMS?

When developers think MFA, they often think SMS: send a six-digit code to a phone number, user enters it, done. SMS MFA is better than no MFA, but it has serious structural weaknesses that make it a poor default for any application handling sensitive data.

  • SIM swapping is the most direct attack: an attacker convinces a mobile carrier's customer support to transfer the victim's phone number to a new SIM under the attacker's control. Once they have the number, they receive every SMS, including OTPs. This attack does not require technical skill, only social engineering, and it has been used to drain cryptocurrency wallets, compromise developer accounts, and bypass MFA at major companies.
  • SMS is not end-to-end encrypted. OTP codes can be intercepted in transit over the cellular network, by malware on the user's device, or through APIs that SMS aggregators expose to third parties.
  • Real-time phishing kits can capture SMS OTPs automatically. A user enters credentials on a convincing fake login page, the attacker triggers a real MFA prompt, the user receives and enters the genuine SMS code on the fake page, and the attacker completes the login within seconds. SMS MFA is not phishing-resistant.
  • Phone numbers are not stable identifiers. Carriers recycle numbers. A user who changes providers loses their number. If a recycled number reaches a new owner, that person could receive OTPs for services tied to the old owner's account.

NIST deprecated SMS as a secure MFA method in their 2017 revision of Special Publication 800-63 and has not reversed that position. Enterprise security reviewers routinely flag SMS MFA as a compliance concern.

WorkOS AuthKit does not support SMS MFA for these reasons. The supported method is TOTP via an authenticator app: Google Authenticator, Authy, 1Password, or any app that supports the TOTP standard. TOTP codes are generated on-device, never transmitted over a network until the user enters them, and are time-limited to 30-second windows. They are phishing-resistant in a way that SMS is not: a code entered on a fake site is expired by the time an attacker could use it.

How MFA works in TanStack Start

In TanStack Start, authentication lives in server functions and the session. MFA adds a layer on top of both.

At login, WorkOS AuthKit handles the MFA challenge as part of the hosted authentication flow. If the user has MFA enabled (or if MFA is required globally), they see a TOTP prompt after entering their password. AuthKit handles the TOTP validation and only creates a session after both factors are verified.

For step-up authentication, WorkOS now ships a first-class mechanism: every access token carries an auth_time claim (a Unix timestamp of the user's last active authentication), and the authorization endpoint accepts a max_age parameter. When you want to gate a sensitive operation, you check auth_time from the token against your threshold. If the session is stale, you redirect to /user_management/authorize with max_age set to the required freshness window. WorkOS re-challenges the user using whatever method they originally authenticated with and issues fresh tokens with an updated auth_time. The session ID stays the same throughout.

Enabling MFA with WorkOS AuthKit

The hosted flow

For most applications, enabling MFA is a single dashboard toggle. In the WorkOS dashboard under Authentication, enable Multi-Factor Authentication. From that point, new and existing users are required to enroll in TOTP with an authenticator app before they can sign in.

AuthKit handles the full enrollment UX: it prompts the user to scan a QR code with their authenticator app, asks them to verify they can generate codes correctly, and only completes login after successful verification. On subsequent logins, it prompts for the current TOTP code after the password step.

The MFA requirement does not apply to SSO users. Enterprise users who authenticate through their company's identity provider are governed by that provider's MFA policies, not yours.

Your TanStack Start code does not change for the hosted flow. The session established after a successful MFA login is identical in structure to a session from password-only login. The difference is that auth.authenticationMethod will reflect the additional factor.

  
// src/routes/api/auth/callback.tsx
import { createFileRoute } from '@tanstack/react-router'
import { handleCallbackRoute } from '@workos-inc/authkit-tanstack-react-start'
import { db } from '../../lib/db'

export const Route = createFileRoute('/api/auth/callback')({
  server: {
    handlers: {
      GET: handleCallbackRoute({
        onSuccess: async ({ user, authenticationMethod }) => {
          // authenticationMethod tells you how the user authenticated
          // Possible values include 'Password', 'SSO', 'MagicAuth', 'GoogleOauth',
          // 'GithubOauth', 'MicrosoftOauth', and combinations like 'Password+Totp'
          await db.users.upsert({
            where: { workosId: user.id },
            create: { workosId: user.id, email: user.email },
            update: { email: user.email, lastAuthMethod: authenticationMethod },
          })
        },
      }),
    },
  },
})
  

Letting users manage their own MFA

If you want to give users control over their own MFA enrollment rather than making it mandatory globally, the WorkOS MFA API lets you trigger enrollment programmatically. A user clicks "Enable two-factor authentication" in your settings UI, you call the enrollment endpoint, and AuthKit walks them through the QR code scan.

  
// src/server/mfa.ts
import { createServerFn } from '@tanstack/react-start'
import { WorkOS } from '@workos-inc/node'
import { getAuth } from '@workos-inc/authkit-tanstack-react-start'

const workos = new WorkOS(process.env.WORKOS_API_KEY!)

export const enrollMfaFactor = createServerFn({ method: 'POST' }).handler(
  async () => {
    const auth = await getAuth()

    if (!auth.user) {
      throw new Error('Unauthorized')
    }

    const { authenticationFactor, authenticationChallenge } =
      await workos.userManagement.enrollAuthFactor({
        userId: auth.user.id,
        type: 'totp',
        totpIssuer: 'Your App Name',
        totpUser: auth.user.email,
      })

    // Return the QR code and secret for the user to scan
    // Never log or store these - they are one-time enrollment values
    return {
      qrCode: authenticationFactor.totp.qrCode,
      secret: authenticationFactor.totp.secret,
      challengeId: authenticationChallenge.id,
    }
  }
)
  

The enrollment response includes a base64-encoded QR code image and a plain-text secret. Show the QR code to the user so they can scan it with their authenticator app. Show the secret as a fallback for users who cannot scan QR codes. After enrollment, ask them to enter a code to confirm the setup worked before saving the factor.

  
// src/server/mfa.ts
export const verifyMfaEnrollment = createServerFn({ method: 'POST' })
  .inputValidator(
    z.object({
      challengeId: z.string(),
      code: z.string().length(6),
    })
  )
  .handler(async ({ data }) => {
    const auth = await getAuth()

    if (!auth.user) {
      throw new Error('Unauthorized')
    }

    const { valid } = await workos.userManagement.verifyAuthChallenge({
      challengeId: data.challengeId,
      code: data.code,
    })

    if (!valid) {
      return { success: false, error: 'Invalid code. Check your authenticator app and try again.' }
    }

    // Mark MFA as enrolled in your database
    await db.users.update({
      where: { workosId: auth.user.id },
      data: { mfaEnrolled: true },
    })

    return { success: true }
  })
  
  
// src/components/MfaEnrollment.tsx
import { useState } from 'react'
import { enrollMfaFactor, verifyMfaEnrollment } from '../server/mfa'

export function MfaEnrollment() {
  const [enrollment, setEnrollment] = useState<{
    qrCode: string
    secret: string
    challengeId: string
  } | null>(null)
  const [code, setCode] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [done, setDone] = useState(false)

  const handleStart = async () => {
    const result = await enrollMfaFactor()
    setEnrollment(result)
  }

  const handleVerify = async () => {
    if (!enrollment) return
    const result = await verifyMfaEnrollment({
      data: { challengeId: enrollment.challengeId, code },
    })
    if (result.success) {
      setDone(true)
    } else {
      setError(result.error ?? 'Verification failed')
    }
  }

  if (done) {
    return <p>Two-factor authentication is now enabled on your account.</p>
  }

  if (!enrollment) {
    return (
      <button onClick={handleStart}>Enable two-factor authentication</button>
    )
  }

  return (
    <div>
      <p>Scan this QR code with your authenticator app:</p>
      <img src={enrollment.qrCode} alt="MFA QR code" />
      <p>
        Can't scan? Enter this secret manually:{' '}
        <code>{enrollment.secret}</code>
      </p>
      <p>Enter the six-digit code from your authenticator app to confirm:</p>
      <input
        type="text"
        inputMode="numeric"
        maxLength={6}
        value={code}
        onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
        placeholder="000000"
      />
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <button onClick={handleVerify} disabled={code.length !== 6}>
        Verify
      </button>
    </div>
  )
}
  

Listing enrolled factors

To show users their current MFA status, or to let them manage or remove enrolled factors, use the list endpoint:

  
export const listMfaFactors = createServerFn({ method: 'GET' }).handler(
  async () => {
    const auth = await getAuth()
    if (!auth.user) throw new Error('Unauthorized')

    const { data: factors } = await workos.userManagement.listAuthFactors({
      userId: auth.user.id,
    })

    return factors.map((factor) => ({
      id: factor.id,
      type: factor.type,
      createdAt: factor.createdAt,
    }))
  }
)
  

Step-up authentication on server functions

This is the part most MFA guides skip. Once a user is signed in with MFA, subsequent sensitive operations should not just trust the session. A session that is eight hours old is not the same security assurance as one from five minutes ago.

WorkOS now ships step-up authentication as a first-class feature. Every access token carries an auth_time claim: the Unix timestamp of the user's last active authentication. Refreshing the token does not bump auth_time. Only a real interactive re-authentication does. This gives you a reliable signal for freshness that does not depend on session metadata you manage yourself.

Step-up authentication is the right pattern for:

  • Deleting an account or organization
  • Changing an email address or password
  • Transferring ownership
  • Approving a payment or financial transaction
  • Viewing sensitive data like API keys or credentials
  • Any action that is difficult or impossible to reverse

Checking auth_time in a server function

The flow is:

  1. Your server function reads auth_time from the access token and checks it against your threshold.
  2. If the session is stale, you return a signal to the client to trigger re-authentication.
  3. The client redirects to /user_management/authorize with max_age set to your freshness window in seconds.
  4. WorkOS re-challenges the user using whatever method they originally authenticated with: TOTP prompt for MFA users, password screen for password users, provider redirect for social and SSO users.
  5. After re-authentication, WorkOS issues fresh tokens with an updated auth_time and redirects back to your callback.
  6. The client retries the original action with the fresh session.

The session ID (sid) is unchanged throughout. The user does not have to log in again, only re-verify.

  
// src/lib/step-up.ts
import { getAuth } from '@workos-inc/authkit-tanstack-react-start'
import { WorkOS } from '@workos-inc/node'

const workos = new WorkOS(process.env.WORKOS_API_KEY!)

// How recently the user must have authenticated for step-up operations (in seconds)
export const STEP_UP_MAX_AGE = 5 * 60 // 5 minutes

export async function checkAuthFreshness(maxAgeSeconds = STEP_UP_MAX_AGE) {
  const auth = await getAuth()

  if (!auth.user) {
    throw new Error('Unauthorized')
  }

  // auth_time comes from the access token JWT claim
  const authTime = auth.authTime // Unix timestamp in seconds
  const ageSeconds = Date.now() / 1000 - authTime

  if (ageSeconds > maxAgeSeconds) {
    return { fresh: false, user: auth.user }
  }

  return { fresh: true, user: auth.user }
}

export function getStepUpUrl(request: Request, maxAge = STEP_UP_MAX_AGE) {
  // Build the authorization URL with max_age to force re-authentication
  // if the session is older than maxAge seconds
  const returnTo = new URL(request.url).pathname

  const authUrl = workos.userManagement.getAuthorizationUrl({
    clientId: process.env.WORKOS_CLIENT_ID!,
    redirectUri: process.env.WORKOS_REDIRECT_URI!,
    maxAge,
    state: JSON.stringify({ returnTo }),
  })

  return authUrl
}
  
  
// src/server/account.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { checkAuthFreshness } from '../lib/step-up'
import { db } from '../lib/db'

export const deleteAccount = createServerFn({ method: 'POST' }).handler(
  async () => {
    const { fresh, user } = await checkAuthFreshness()

    if (!fresh) {
      // Signal the client to trigger step-up re-authentication
      return { requiresStepUp: true }
    }

    await db.users.delete({ where: { workosId: user.id } })
    return { success: true }
  }
)

export const rotateApiKey = createServerFn({ method: 'POST' })
  .inputValidator(z.object({ keyId: z.string() }))
  .handler(async ({ data }) => {
    const { fresh, user } = await checkAuthFreshness()

    if (!fresh) {
      return { requiresStepUp: true }
    }

    const newKey = await db.apiKeys.rotate(data.keyId, user.id)
    return { success: true, newKey }
  })
  

Triggering the re-authentication redirect

When a server function returns requiresStepUp: true, the client redirects to the WorkOS authorization endpoint with max_age. WorkOS handles the challenge UI entirely. After re-authentication, the user is redirected to your callback route, the session tokens are refreshed with a new auth_time, and the client can retry the original action.

  
// src/routes/_authed/settings/danger.tsx
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { deleteAccount } from '../../../server/account'

export const Route = createFileRoute('/_authed/settings/danger')({
  loader: async () => {
    // Pass the step-up URL to the component so it can trigger re-auth
    const stepUpUrl = await getStepUpUrl()
    return { stepUpUrl }
  },
  component: DangerZone,
})

function DangerZone({ loaderData }) {
  const { stepUpUrl } = loaderData
  const [loading, setLoading] = useState(false)

  const handleDeleteAccount = async () => {
    setLoading(true)
    const result = await deleteAccount()

    if (result.requiresStepUp) {
      // Redirect to WorkOS for re-authentication.
      // After re-auth, the user lands back in the callback route,
      // then is redirected to returnTo (this page), and can retry.
      window.location.href = stepUpUrl
      return
    }

    if (result.success) {
      window.location.href = '/goodbye'
    }

    setLoading(false)
  }

  return (
    <div>
      <h2>Danger zone</h2>
      <p>
        Deleting your account is permanent. You will be asked to verify
        your identity before this action is completed.
      </p>
      <button onClick={handleDeleteAccount} disabled={loading}>
        {loading ? 'Verifying...' : 'Delete account'}
      </button>
    </div>
  )
}
  

The returnTo value in the step-up URL's state parameter lets your callback redirect the user back to the page they were on after re-authentication completes. Wire it up in your callback handler:

  
// src/routes/api/auth/callback.tsx
import { handleCallbackRoute } from '@workos-inc/authkit-tanstack-react-start'

export const Route = createFileRoute('/api/auth/callback')({
  server: {
    handlers: {
      GET: handleCallbackRoute({
        onSuccess: async ({ user, authenticationMethod, state }) => {
          await db.users.upsert({
            where: { workosId: user.id },
            create: { workosId: user.id, email: user.email },
            update: { email: user.email, lastAuthMethod: authenticationMethod },
          })

          // If this was a step-up flow, redirect back to the originating page
          if (state) {
            const { returnTo } = JSON.parse(state)
            if (returnTo) return Response.redirect(new URL(returnTo, process.env.WORKOS_REDIRECT_URI!))
          }
        },
      }),
    },
  },
})
  

Using max_age=0 to always force re-authentication

For the most sensitive operations, you may want to require re-authentication regardless of how recently the user last authenticated. Pass max_age=0 to always trigger a fresh challenge:

  
// Force re-authentication every time, no matter how recent the last auth was
const stepUpUrl = workos.userManagement.getAuthorizationUrl({
  clientId: process.env.WORKOS_CLIENT_ID!,
  redirectUri: process.env.WORKOS_REDIRECT_URI!,
  maxAge: 0,
})
  

This is the right choice for payment confirmation, account deletion, or any action where you want a guaranteed fresh identity signal rather than a freshness window.

MFA for organizational policies

For B2B applications, MFA enforcement often needs to be per-organization rather than global. Some customers require MFA for all users. Others leave it optional. Still others enforce it only for admin users.

WorkOS organization policies let you set MFA requirements at the organization level. Navigate to an organization in the WorkOS dashboard and set the authentication policy. Users in that organization will be required to complete MFA on login regardless of the global setting.

This gives enterprise customers a way to enforce their own security policies without your team managing it per-tenant. Their IT admin can toggle MFA requirements for their organization through the WorkOS Admin Portal, and the enforcement happens automatically in the authentication flow.

Handling MFA in server function middleware

If you have many server functions that require step-up authentication, a middleware approach removes the duplication. Rather than calling checkAuthFreshness in every handler, you can create a middleware that performs the check and passes the verified user through context.

Because step-up authentication requires a client-side redirect rather than a thrown error, the middleware is best used for operations where you want a hard server-side rejection rather than a graceful redirect. For the redirect pattern, keep the checkAuthFreshness call in the handler and return requiresStepUp: true as shown above.

For operations where you want the server to reject the request outright if auth_time is stale (for example, in an API context where there is no redirect possible), use middleware that throws:

  
// src/middleware/step-up.ts
import { createMiddleware } from '@tanstack/react-start'
import { checkAuthFreshness } from '../lib/step-up'

export const requireFreshAuth = createMiddleware().server(
  async ({ next }) => {
    const { fresh, user } = await checkAuthFreshness()

    if (!fresh) {
      // Hard rejection - use this for API endpoints, not UI flows
      throw new Response(
        JSON.stringify({ error: 'Re-authentication required', code: 'STEP_UP_REQUIRED' }),
        { status: 403, headers: { 'Content-Type': 'application/json' } }
      )
    }

    return next({ context: { user } })
  }
)
  
  
// src/server/billing.ts
import { createServerFn } from '@tanstack/react-start'
import { requireFreshAuth } from '../middleware/step-up'

export const updatePaymentMethod = createServerFn({ method: 'POST' })
  .middleware([requireFreshAuth])
  .handler(async ({ context }) => {
    // Reaches here only if auth_time is within the freshness window
    await db.billing.updatePaymentMethod(context.user.id)
    return { success: true }
  })

export const cancelSubscription = createServerFn({ method: 'POST' })
  .middleware([requireFreshAuth])
  .handler(async ({ context }) => {
    await db.subscriptions.cancel(context.user.id)
    return { success: true }
  })
  

Letting users see and manage their factors

A good security settings page shows users which factors are enrolled and lets them remove them. Do not let users remove their only enrolled factor without confirming they understand they will lose MFA protection:

  
// src/routes/_authed/settings/security.tsx
import { createFileRoute } from '@tanstack/react-router'
import { listMfaFactors } from '../../../server/mfa'
import { MfaEnrollment } from '../../../components/MfaEnrollment'

export const Route = createFileRoute('/_authed/settings/security')({
  loader: async () => {
    const factors = await listMfaFactors()
    return { factors }
  },
  component: SecuritySettings,
})

function SecuritySettings({ loaderData }) {
  const { factors } = loaderData

  return (
    <div>
      <h2>Two-factor authentication</h2>

      {factors.length === 0 ? (
        <div>
          <p>
            Two-factor authentication is not enabled. Add an authenticator app
            to protect your account.
          </p>
          <MfaEnrollment />
        </div>
      ) : (
        <div>
          <p>Two-factor authentication is active.</p>
          <ul>
            {factors.map((factor) => (
              <li key={factor.id}>
                Authenticator app, added{' '}
                {new Date(factor.createdAt).toLocaleDateString()}
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  )
}
  

Testing MFA flows

MFA flows are some of the most important to test because a bug here can lock users out of their accounts entirely. Two categories of tests matter:

Unit tests for the step-up logic. The freshness check is a pure function of the session timestamp and the configured window. Test it directly:

  
// src/lib/step-up.test.ts
import { describe, it, expect, vi } from 'vitest'

const STEP_UP_MAX_AGE = 5 * 60 // 5 minutes in seconds

function isAuthFresh(authTimeUnix: number, maxAgeSeconds: number): boolean {
  const ageSeconds = Date.now() / 1000 - authTimeUnix
  return ageSeconds <= maxAgeSeconds
}

describe('auth_time freshness check', () => {
  it('accepts an auth_time within the window', () => {
    const twoMinutesAgo = Date.now() / 1000 - 2 * 60
    expect(isAuthFresh(twoMinutesAgo, STEP_UP_MAX_AGE)).toBe(true)
  })

  it('rejects an auth_time outside the window', () => {
    const tenMinutesAgo = Date.now() / 1000 - 10 * 60
    expect(isAuthFresh(tenMinutesAgo, STEP_UP_MAX_AGE)).toBe(false)
  })

  it('rejects an auth_time exactly at the boundary', () => {
    const exactlyAtBoundary = Date.now() / 1000 - STEP_UP_MAX_AGE
    expect(isAuthFresh(exactlyAtBoundary, STEP_UP_MAX_AGE)).toBe(false)
  })

  it('always rejects when max_age is 0', () => {
    const oneSecondAgo = Date.now() / 1000 - 1
    expect(isAuthFresh(oneSecondAgo, 0)).toBe(false)
  })
})
  

Integration tests for protected server functions. Mock the getAuth response to simulate different MFA states and verify that the server function returns the expected result:

  
// src/server/account.test.ts
import { describe, it, expect, vi } from 'vitest'
import { deleteAccount } from './account'

vi.mock('@workos-inc/authkit-tanstack-react-start', () => ({
  getAuth: vi.fn(),
}))

import { getAuth } from '@workos-inc/authkit-tanstack-react-start'

function mockAuth(authAgeSeconds: number) {
  const authTime = Date.now() / 1000 - authAgeSeconds
  ;(getAuth as ReturnType<typeof vi.fn>).mockResolvedValue({
    user: { id: 'user-1', email: 'user@example.com' },
    authTime,
  })
}

describe('deleteAccount', () => {
  it('returns requiresStepUp when auth_time is stale', async () => {
    mockAuth(10 * 60) // authenticated 10 minutes ago, outside 5-minute window
    const result = await deleteAccount()
    expect(result).toEqual({ requiresStepUp: true })
  })

  it('allows deletion when auth_time is fresh', async () => {
    mockAuth(2 * 60) // authenticated 2 minutes ago, within window
    const result = await deleteAccount()
    expect(result).toEqual({ success: true })
  })
})
  

Production checklist

  • Enable MFA in the WorkOS dashboard under Authentication before testing. The toggle must be on for new users to see the enrollment prompt.
  • If you are building optional MFA enrollment, always confirm enrollment by asking the user to enter a code immediately after scanning the QR code. Do not save the factor until the code is verified.
  • Show the TOTP secret as a fallback for users who cannot scan QR codes. Some password managers and enterprise authenticator apps require manual entry.
  • Set max_age based on the sensitivity of the operation. Five minutes is a reasonable default for account changes and credential rotation. Use max_age=0 for payment confirmation or any action where a guaranteed fresh identity signal matters more than convenience.
  • Always include a returnTo parameter in your step-up state so users land back on the page they were on after re-authentication completes.
  • Test the locked-out path: what happens if a user loses access to their authenticator app? Define and document your account recovery process before enabling mandatory MFA globally.
  • The MFA requirement does not apply to SSO users. If you are enforcing MFA org-wide, communicate to customers that their SSO users are governed by their identity provider's MFA policies, not yours.
  • Do not log TOTP secrets or QR codes. They are single-use enrollment values and should never appear in application logs.
  • Test code expiry: TOTP codes are valid for a 30-second window. Test that entering a code from the previous window is rejected, and that the current code succeeds consistently.
  • The authentication.reauthenticated event fires on every successful step-up. Wire it into your audit log pipeline if your application is subject to SOC 2, HIPAA, or PCI-DSS compliance requirements.

Conclusion

MFA in TanStack Start is straightforward at the login level: enable the WorkOS dashboard toggle, and AuthKit handles enrollment and verification through the hosted flow. The more interesting work is step-up authentication, which extends the security guarantee beyond the login event and into the sensitive operations that live in your server functions.

WorkOS now ships step-up authentication as a first-class feature, built on auth_time JWT claims and max_age on the authorization endpoint. There is no custom challenge UI to build, no TOTP verification to wire up manually. The re-authentication flow is hosted by AuthKit and adapts to whatever method the user originally authenticated with.

The architecture is the same one that runs through every guide in this series: server functions are the security boundary, and the check belongs inside the function, not just in the route guard that renders the page. Step-up MFA applies that same principle to the highest-risk operations in your application.

WorkOS includes MFA and step-up authentication with no additional charge, up to 1 million monthly active users on the free plan.

Sign up for WorkOS and add MFA to your TanStack Start application.