In this article
August 10, 2026
August 10, 2026

How to add step-up authentication to your Next.js app with WorkOS AuthKit

Build a Next.js app with AuthKit sign-in, then gate a destructive action behind a fresh re-authentication using auth_time and max_age.

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

Most apps authenticate a user once and then trust that session for hours. Reading a dashboard and deleting a production database get the same level of proof. That's fine right up until the moment it isn't: a laptop left open, a stolen session cookie, a shared machine.

Step-up authentication fixes the mismatch. Instead of one authentication event at the door, you require a fresh one immediately before the actions that actually matter. GitHub calls it sudo mode. AWS calls it MFA-protected API access. The mechanism underneath is the same: prove it's still you, right now, before this specific thing happens.

In this tutorial you'll build a small Next.js app from an empty directory:

  1. Sign-in with AuthKit and a protected dashboard
  2. A settings page with a destructive action, revoking every API key on the account
  3. A step-up gate on that action, so it only runs if the user authenticated in the last five minutes
  4. A UI that tells the user before they click, and returns them exactly where they were afterward

The finished flow: a user who signed in eight hours ago clicks Revoke all keys, gets bounced through AuthKit for a quick re-verification, lands back on the settings page with the confirmation dialog still open, and completes the action. Their session ID never changes and they never lose their place.

How step-up works in AuthKit

AuthKit implements step-up with two standard OIDC fields, so if you've done this before with another provider the shape will be familiar.

auth_time is a claim on every AuthKit access token. It's a Unix timestamp of the user's most recent active authentication. The important word is active: a token refresh does not move auth_time. Only a real interactive sign-in does. This is what makes it a trustworthy freshness signal; a session that has been silently refreshing for eight hours still reports an auth_time of eight hours ago.

max_age is a parameter on the authorization URL, defined in RFC 9470. You pass a number of seconds, and AuthKit forces a re-authentication if the user's last active authentication is older than that. max_age=0 always forces one.

Put together, the loop is:

  1. Your server reads auth_time from the access token and compares it to your freshness window.
  2. If it's stale, you redirect to AuthKit with max_age set.
  3. AuthKit challenges the user with whatever method they actually use: password re-entry, an MFA factor, a bounce back through their SSO provider. You don't pick; AuthKit picks based on the factors the user has.
  4. The user comes back with a new access token carrying an updated auth_time. The session ID (sid) is unchanged.
  5. An authentication.reauthenticated event fires, which you can log or act on.

The part worth internalizing: auth_time on the client is a hint, max_age at the authorization endpoint is the enforcement. You can read the claim client-side to decide what UI to show, but the actual guarantee comes from AuthKit refusing to issue a fresh token without a real challenge. Build accordingly; check server-side, always.

What you'll need

  • Node.js 22.11 or later
  • A WorkOS account with an AuthKit environment
  • @workos-inc/authkit-nextjs 4.2.0 or later (checkRecentAuth, useRecentAuth, and maxAge landed in 4.2.0)
  • @workos-inc/node 10.7.0 or later (this is where maxAge was added to getAuthorizationUrl; the Next.js library forwards to it)

If you're on an older @workos-inc/node, the maxAge option is typed as never and TypeScript will tell you before runtime does.

Step 1: Scaffold the app

  
npx create-next-app@latest stepup-demo --typescript --app --tailwind --no-src-dir
cd stepup-demo
npm install @workos-inc/authkit-nextjs @workos-inc/node
  

Create .env.local:

  
WORKOS_CLIENT_ID="client_..."
WORKOS_API_KEY="sk_test_..."
WORKOS_COOKIE_PASSWORD="..."
NEXT_PUBLIC_WORKOS_REDIRECT_URI="http://localhost:3000/callback"
  

WORKOS_COOKIE_PASSWORD encrypts the session cookie and must be at least 32 characters:

  
openssl rand -base64 24
  

In the WorkOS dashboard, open your application, go to Redirects, and add http://localhost:3000/callback as a redirect URI and http://localhost:3000/sign-in as the sign-in endpoint.

The sign-in endpoint is easy to skip and annoying to debug later — without it, dashboard-initiated flows like impersonation fail the PKCE check on callback.

Step 2: Wire up basic authentication

Three files and you have working sign-in. Nothing here is step-up specific yet.

The callback route exchanges the authorization code for a session and drops the encrypted cookie.

  
// app/callback/route.ts
import { handleAuth } from '@workos-inc/authkit-nextjs';

export const GET = handleAuth({ returnPathname: '/dashboard' });
  

The sign-in route kicks off the AuthKit flow.

  
// app/sign-in/route.ts
import { getSignInUrl } from '@workos-inc/authkit-nextjs';
import { redirect } from 'next/navigation';

export const GET = async () => {
  return redirect(await getSignInUrl());
};
  

The middleware keeps sessions fresh and makes withAuth() work in server components.

  
// middleware.ts  (Next.js ≤15 — name it proxy.ts and use authkitProxy on 16+)
import { authkitMiddleware } from '@workos-inc/authkit-nextjs';

export default authkitMiddleware();

export const config = {
  matcher: ['/', '/dashboard', '/settings/:path*'],
};
  

Your matcher has to cover every route where you call withAuth(), including the routes that host your sensitive server actions. A server action runs on the path of the page that invoked it, so if /settings isn't matched, checkRecentAuth() will throw later with a "not covered by the AuthKit middleware" error. This trips up almost everyone once.

Finally, wrap the app in the provider so client hooks have something to read:

  
// app/layout.tsx
import { AuthKitProvider } from '@workos-inc/authkit-nextjs/components';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <AuthKitProvider>{children}</AuthKitProvider>
      </body>
    </html>
  );
}
  

Step 3: Build a protected dashboard

  
// app/dashboard/page.tsx
import Link from 'next/link';
import { withAuth, signOut } from '@workos-inc/authkit-nextjs';

export default async function DashboardPage() {
  const { user } = await withAuth({ ensureSignedIn: true });

  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="text-2xl font-semibold">Welcome back, {user.firstName ?? user.email}</h1>

      <Link href="/settings" className="mt-6 inline-block underline">
        Account settings
      </Link>

      <form
        action={async () => {
          'use server';
          await signOut();
        }}
      >
        <button type="submit" className="mt-8 text-sm underline">
          Sign out
        </button>
      </form>
    </main>
  );
}
  

ensureSignedIn: true redirects anonymous visitors to AuthKit rather than returning null. Note that it redirects internally, so don't wrap it in a try/catch; Next.js implements redirects by throwing, and catching it will surface as a NEXT_REDIRECT error.

Run npm run dev, visit /dashboard, sign up, and you should land back on a working page. That's ordinary AuthKit. Now for the interesting part.

Step 4: Read auth_time

Before gating anything, look at the claim you're about to depend on:

  
import { getTokenClaims } from '@workos-inc/authkit-nextjs';

const { auth_time } = await getTokenClaims();
console.log(new Date((auth_time as number) * 1000));
  

Sign in, note the timestamp, then hard-refresh a few times. The timestamp doesn't move, even as the middleware rotates your access token in the background. That stability is the whole point. It's a record of when the user last proved something, not when the system last issued a token.

Step 5: Gate a sensitive action

Here's the action worth protecting: revoking every API key on an account. Irreversible, and it breaks production for whoever depends on those keys.

The guard is checkRecentAuth, which reads auth_time off the current access token and tells you whether it's within your window. It returns data and never redirects, so you stay in control of what happens next, which matters inside a server action, where a redirect would be the wrong response to send back to a form submission.

  
// app/settings/actions.ts
'use server';

import { checkRecentAuth, withAuth } from '@workos-inc/authkit-nextjs';
import { revalidatePath } from 'next/cache';

// Five minutes. Long enough to be usable, short enough to mean something.
const SENSITIVE_ACTION_MAX_AGE = 300;

export type RevokeResult =
  | { status: 'ok'; revoked: number }
  | { status: 'reauth_required' }
  | { status: 'error'; message: string };

export async function revokeAllApiKeys(): Promise<RevokeResult> {
  const { user } = await withAuth({ ensureSignedIn: true });

  const { isStale, authenticatedAt } = await checkRecentAuth({
    maxAge: SENSITIVE_ACTION_MAX_AGE,
  });

  if (isStale) {
    console.info('Step-up required for %s (last auth: %s)', user.id, authenticatedAt ?? 'unknown');
    return { status: 'reauth_required' };
  }

  const revoked = await db.apiKeys.revokeAllForUser(user.id);

  revalidatePath('/settings');
  return { status: 'ok', revoked };
}
  

checkRecentAuth returns { authenticatedAt, isStale }. authenticatedAt is a Date, or null if the token carried no usable auth_time. It fails closed: a missing or malformed claim reports as stale, so a token that predates step-up support won't silently sail through your guard. Clock skew is handled the friendly way, an auth_time slightly in the future counts as recent rather than stale.

This check is the security boundary. Everything in the next two steps is user experience layered on top of it, and none of it can be trusted on its own.

Step 6: Send the user through re-authentication

When the action returns reauth_required, you need to route the user through AuthKit with max_age set. A dedicated route handler keeps that logic in one place:

  
// app/step-up/route.ts
import { getSignInUrl } from '@workos-inc/authkit-nextjs';
import { redirect } from 'next/navigation';
import type { NextRequest } from 'next/server';

const MAX_AGE = 300;

export const GET = async (request: NextRequest) => {
  const requested = request.nextUrl.searchParams.get('returnTo') ?? '/settings';

  // Only ever return to a path on this app. Never redirect to a
  // user-supplied absolute URL.
  const returnTo = requested.startsWith('/') && !requested.startsWith('//') ? requested : '/settings';

  const url = await getSignInUrl({ maxAge: MAX_AGE, returnTo });

  return redirect(url);
};
  

That returnTo validation is not optional. Anything that takes a redirect target from a query string and hands it to a redirect is an open-redirect bug waiting to be reported, and it's a particularly bad one on an auth route.

getSignInUrl handles the details for you: it generates the PKCE challenge, seals returnTo into the encrypted state parameter, sets the verifier cookie, and forwards maxAge as OIDC max_age. On the way back, handleAuth unseals the state and drops the user at the path you asked for.

Add /step-up and /callback to your middleware matcher:

  
export const config = {
  matcher: ['/', '/dashboard', '/settings/:path*', '/step-up', '/callback'],
};
  

Step 7: Connect the UI

The client component calls the action, and on reauth_required sends the user to the step-up route with enough context to resume.

  
// app/settings/revoke-keys-button.tsx
'use client';

import { useState, useTransition } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRecentAuth } from '@workos-inc/authkit-nextjs/components';
import { revokeAllApiKeys } from './actions';

const MAX_AGE = 300;

export function RevokeKeysButton() {
  const router = useRouter();
  const searchParams = useSearchParams();

  // Reopen the dialog automatically when the user returns from step-up.
  const [confirming, setConfirming] = useState(searchParams.get('resume') === 'revoke-keys');
  const [message, setMessage] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();

  // Presentation only. The real check happens in the server action.
  const { isStale, loading } = useRecentAuth({ maxAge: MAX_AGE });

  function stepUp() {
    const returnTo = '/settings?resume=revoke-keys';
    router.push(`/step-up?returnTo=${encodeURIComponent(returnTo)}`);
  }

  function confirm() {
    startTransition(async () => {
      const result = await revokeAllApiKeys();

      if (result.status === 'reauth_required') {
        stepUp();
        return;
      }

      setConfirming(false);
      setMessage(
        result.status === 'ok' ? `Revoked ${result.revoked} API keys.` : result.message,
      );
    });
  }

  if (!confirming) {
    return (
      <div>
        <button
          onClick={() => setConfirming(true)}
          className="rounded bg-red-600 px-4 py-2 text-white"
        >
          Revoke all API keys
        </button>
        {message && <p className="mt-2 text-sm">{message}</p>}
      </div>
    );
  }

  return (
    <div className="rounded border border-red-200 p-4">
      <p className="font-medium">Revoke all API keys?</p>
      <p className="mt-1 text-sm text-gray-600">
        Every integration using these keys will stop working immediately.
      </p>

      {!loading && isStale && (
        <p className="mt-3 text-sm text-amber-700">
          You'll be asked to confirm your identity before this runs.
        </p>
      )}

      <div className="mt-4 flex gap-2">
        <button
          onClick={confirm}
          disabled={pending}
          className="rounded bg-red-600 px-4 py-2 text-white disabled:opacity-50"
        >
          {pending ? 'Working…' : 'Yes, revoke them'}
        </button>
        <button onClick={() => setConfirming(false)} className="px-4 py-2">
          Cancel
        </button>
      </div>
    </div>
  );
}
  

And the page that renders it:

  
// app/settings/page.tsx
import { Suspense } from 'react';
import { withAuth } from '@workos-inc/authkit-nextjs';
import { RevokeKeysButton } from './revoke-keys-button';

export default async function SettingsPage() {
  await withAuth({ ensureSignedIn: true });

  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="text-2xl font-semibold">Account settings</h1>
      <section className="mt-8">
        <h2 className="text-lg font-medium">Danger zone</h2>
        <div className="mt-4">
          <Suspense>
            <RevokeKeysButton />
          </Suspense>
        </div>
      </section>
    </main>
  );
}
  

useRecentAuth reads auth_time from the token already in client memory and returns { loading, authenticatedAt, isStale }; the same shape as the server helper. Use it to set expectations, never to decide. A user who edits your JavaScript can flip isStale to false all day and the server action will still refuse.

Notice what the resume flow does not do: it doesn't fire the destructive action automatically after the redirect. It reopens the dialog and asks again. A user coming back from an auth provider is mid-navigation; executing an irreversible operation for them on arrival is the kind of thing that produces incident reports. The step-up proved identity, the confirmation still proves intent.

Step 8: Test it

The freshness window makes this awkward to test by hand unless you're willing to wait five minutes. Two shortcuts:

Temporarily set maxAge: 0 in both actions.ts and step-up/route.ts. Every attempt now triggers a step-up, so you can exercise the whole loop in seconds. This is also the right setting permanently for genuinely dangerous operations, where "you authenticated four minutes ago" isn't good enough.

Or shorten the window to 10 seconds, click through, wait, and click again to watch the same button take both paths.

What you should see on the step-up path: click Yes, revoke them → a brief redirect to AuthKit → a challenge matched to how you signed in (password re-entry for password users, a code for magic auth, a bounce through the IdP for SSO users) → back on /settings with the dialog open → click again → it completes.

Watch two things in particular. Your session cookie's sid is unchanged throughout; the user was never signed out, just re-verified. And auth_time has jumped forward to the moment of the challenge.

Step 9: Log the event

Every completed step-up emits authentication.reauthenticated, carrying the user ID, session ID, method used, and the new auth_time. It flows through the same event pipeline as the rest of your AuthKit events, so you can consume it from a webhook or poll the Events API.

This is the record auditors ask for: proof that identity was re-verified before a privileged operation, not just that a session existed. Pipe it to your audit log alongside the action it gated, and "who revoked the keys and how did we know it was them" becomes one query instead of a forensics exercise.

Choosing a freshness window

The window is a product decision, not a security default. A rough guide:

Action Suggested max_age
Viewing secrets, downloading data exports 15 minutes (900)
Changing billing details, inviting admins 5 minutes (300)
Rotating or revoking credentials, changing MFA settings 5 minutes (300)
Deleting an account, transferring ownership 0 — always challenge

Below about 60 seconds you're mostly generating friction. A user who authenticated 90 seconds ago is the same user; making them prove it again teaches them to click through challenges without reading, which is exactly the reflex you don't want when a real phishing prompt shows up.

Consider grouping actions into tiers with shared windows rather than picking a number per action. Three tiers your team can reason about beats twenty individually-tuned constants nobody remembers the rationale for.

Common mistakes

  • Trusting the client check. useRecentAuth is presentation. If your server action doesn't call checkRecentAuth, you don't have step-up — you have a suggestion.
  • Forgetting the middleware matcher. checkRecentAuth calls withAuth internally, which needs the middleware headers. A sensitive action on an unmatched route throws at runtime, not build time.
  • Assuming a refresh counts. It doesn't, by design. If you find yourself surprised that a busy user still shows a stale auth_time, that's the feature working.
  • Dropping the user's context. Re-authentication is a full round trip out of your app. If they come back to a blank dashboard instead of the half-filled form they left, they'll abandon the task. Seal the return path into state (getSignInUrl's returnTo does this for you) and restore what you can.
  • Open-redirecting through returnTo. Validate that it's a relative path before you hand it to redirect. Every time.

Wrapping up

The whole gate is three moving parts: read auth_time on the server, redirect with max_age when it's stale, resume where the user left off. Everything else is deciding which actions deserve it.

Start with one (the most destructive thing your app can do) and add the guard there. You'll find the second and third are five-minute changes once the step-up route exists.

The full reference lives in the reauthentication docs, and the max_age parameter is documented on the authorization URL reference.