In this article
August 17, 2026
August 17, 2026

Step-up authentication for AI agents

Your MCP server hands an agent a token that stays valid for hours. Here is how to put a human back in the loop before it does something irreversible.

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

An agent connects to your MCP server, completes an OAuth flow, and receives an access token. That token is valid for the next hour, and its refresh token for far longer. From that point on, every tool call arrives with the same credential and the same level of trust.

Most of the time that is exactly what you want. The agent lists resources, reads records, summarizes a dashboard. Nobody wants a confirmation prompt for a read.

Then the agent calls delete_environment.

The token is still valid. The user did authorize this client. But the human who authorized it three hours ago was agreeing to let an assistant help with their work, not specifically agreeing to destroy a production environment at 4pm on a Thursday. The authorization was real. The consent for this particular action was not.

This tutorial builds the missing piece: an MCP server that refuses to execute a sensitive tool until a human re-verifies their identity in a browser and approves the exact operation. The agent never sees a credential, the approval is cryptographically bound to the arguments it was granted for, and every step lands in your audit log.

The state of the art, honestly

Before building anything, it is worth knowing what the standards say and where the implementations actually are, because there is a gap between the two right now.

RFC 9470 defines exactly this scenario. A resource server that needs a fresher authentication returns a 401 with WWW-Authenticate: Bearer error="insufficient_user_authentication", max_age="300", and the client is expected to run a new authorization request carrying max_age. It is a clean design, and it is the direction the ecosystem is heading.

Two things make it impractical to build on today.

First, MCP client support is thin. Most clients handle the initial 401 with resource_metadata well, because that is the documented onboarding path. Very few implement a mid-session re-authorization triggered by insufficient_user_authentication, and a client that does not understand the challenge will surface it to the model as a generic error. The model will then apologize and try something else, which is the worst possible outcome for a security control.

Second, AuthKit's max_age parameter currently applies to AuthKit flows on /user_management/authorize. MCP clients authorize against the WorkOS Connect endpoint, /oauth2/authorize, where max_age is not currently a documented parameter. So even with a client that speaks RFC 9470 fluently, the re-authorization would not enforce freshness end to end.

What does work today, and what production MCP servers are converging on anyway, is an out of band approval. The tool call pauses, the human approves in a browser where a real authentication ceremony can happen, and the agent resumes. That is what we are building. When RFC 9470 support arrives on both sides, it becomes an optimization of step 2 rather than a rewrite.

What you are building

Three components, two of which you probably already have:

  • Your web app. A Next.js app using AuthKit, with step-up already wired up. If you have not built that part, start with the Next.js step-up tutorial and come back. This tutorial reuses checkRecentAuth and the /step-up route from it.
  • Your MCP server. An Express server using AuthKit as its authorization server, following the MCP guide.
  • A shared approvals table. The handoff between the two.

The flow:

  
Agent                MCP server              Web app (browser)
  |                       |                          |
  |-- delete_environment ->|                         |
  |                       |-- create approval        |
  |<- "needs approval" ---|   (pending, 5 min TTL)   |
  |                       |                          |
  |   [agent tells the user to open the link]        |
  |                       |                          |
  |                       |         <-- user opens --|
  |                       |            checkRecentAuth
  |                       |            stale -> AuthKit re-verify
  |                       |            user reviews + approves
  |                       |-- approval: approved ----|
  |                       |                          |
  |-- check_approval ---->|                          |
  |                       |-- verify + execute       |
  |<- "done" -------------|   (mark consumed)        |
  

The important property: the security decision happens in the browser, where there is a human and a real authentication ceremony. The agent's only role is to carry a request identifier back and forth.

Prerequisites

  • Node.js 22.11 or later
  • A WorkOS environment with AuthKit and MCP configured per the MCP guide: Client ID Metadata Document enabled, and your MCP URL registered as a Resource Indicator
  • The web app from the previous tutorial, with @workos-inc/authkit-nextjs 4.2.0 or later
  • A database both services can reach

Code below targets @modelcontextprotocol/server v2, which implements the 2026-07-28 spec. On SDK v1.x the imports change to @modelcontextprotocol/sdk/server/mcp.js and the tool registration signature is slightly different, but nothing about the approval design changes.

Step 1: Stand up an authenticated MCP server

  
mkdir mcp-server && cd mcp-server
npm init -y
npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express jose zod
  

Token verification is the standard AuthKit MCP middleware. It checks the signature against your environment's JWKS, pins the issuer and audience, and returns a WWW-Authenticate header pointing at your protected resource metadata so clients can discover where to authenticate.

  
// src/auth.ts
import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { NextFunction, Request, Response } from 'express';

const AUTHKIT_DOMAIN = process.env.AUTHKIT_DOMAIN!;
const MCP_URL = process.env.MCP_URL!;

const JWKS = createRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`));

const WWW_AUTHENTICATE = [
  'Bearer error="unauthorized"',
  'error_description="Authorization needed"',
  `resource_metadata="${MCP_URL}/.well-known/oauth-protected-resource"`,
].join(', ');

declare global {
  namespace Express {
    interface Request {
      userId?: string;
    }
  }
}

export async function bearerTokenMiddleware(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.match(/^Bearer (.+)$/)?.[1];

  if (!token) {
    return res
      .set('WWW-Authenticate', WWW_AUTHENTICATE)
      .status(401)
      .json({ error: 'No token provided.' });
  }

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: AUTHKIT_DOMAIN,
      audience: MCP_URL,
    });

    // `sub` is the WorkOS user id. Everything downstream is scoped to it.
    req.userId = payload.sub;
    next();
  } catch {
    return res
      .set('WWW-Authenticate', WWW_AUTHENTICATE)
      .status(401)
      .json({ error: 'Invalid bearer token.' });
  }
}
  

And the server itself:

  
// src/index.ts
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { McpServer } from '@modelcontextprotocol/server';
import { bearerTokenMiddleware } from './auth.js';
import { registerTools } from './tools.js';

const MCP_URL = process.env.MCP_URL!;
const AUTHKIT_DOMAIN = process.env.AUTHKIT_DOMAIN!;

const app = createMcpExpressApp();

// Discovery. Unauthenticated by design: this is how clients find AuthKit.
app.get('/.well-known/oauth-protected-resource', (_req, res) =>
  res.json({
    resource: MCP_URL,
    authorization_servers: [AUTHKIT_DOMAIN],
    bearer_methods_supported: ['header'],
  }),
);

app.post('/mcp', bearerTokenMiddleware, async (req, res) => {
  const server = new McpServer({ name: 'acme-platform', version: '1.0.0' });
  registerTools(server, { userId: req.userId! });

  const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(8000);
  

Note that registerTools receives the userId from the verified token. Every tool closes over the identity of the person who authorized this client, and no tool ever accepts a user id as an argument. An agent that can name a user id can impersonate one.

Step 2: Decide which tools are sensitive

Not every write needs a human. Renaming a resource is annoying to undo; deleting one with its data is not undoable at all. Be deliberate, because a server that asks for approval on everything trains users to approve without reading, which is worse than not asking.

A rough line: require approval when the action destroys data, moves money, changes who has access, or reaches customers.

  
// src/sensitive.ts

/**
 * Tools that cannot execute without a fresh human approval.
 * `maxAge` is the freshness window enforced in the browser, in seconds.
 * Use 0 for actions where "you authenticated two minutes ago" is not enough.
 */
export const SENSITIVE_TOOLS = {
  delete_environment: { maxAge: 0, label: 'Delete an environment and all of its data' },
  rotate_api_keys: { maxAge: 120, label: 'Rotate all API keys' },
  invite_admin: { maxAge: 120, label: 'Grant admin access to a new user' },
  send_broadcast: { maxAge: 120, label: 'Send an email to all users' },
} as const;

export type SensitiveTool = keyof typeof SENSITIVE_TOOLS;
  

Step 3: Build the approvals store

An approval record is a promise about one specific operation. It has to be narrow enough that approving it cannot authorize anything else.

  
// src/approvals.ts
import { createHash, randomUUID } from 'node:crypto';
import type { SensitiveTool } from './sensitive.js';

export type Approval = {
  id: string;
  userId: string;
  tool: SensitiveTool;
  args: Record<string, unknown>;
  argsHash: string;
  status: 'pending' | 'approved' | 'denied' | 'consumed';
  createdAt: Date;
  expiresAt: Date;
  approvedAt: Date | null;
  authenticatedAt: Date | null;
};

const TTL_MS = 5 * 60 * 1000;

/**
 * Stable hash of the tool arguments. Approval is bound to this value, so a
 * request approved for one set of arguments cannot be redeemed for another.
 */
export function hashArgs(args: Record<string, unknown>): string {
  const canonical = JSON.stringify(args, Object.keys(args).sort());
  return createHash('sha256').update(canonical).digest('hex');
}

export async function createApproval(
  userId: string,
  tool: SensitiveTool,
  args: Record<string, unknown>,
): Promise<Approval> {
  const now = new Date();

  return db.approvals.insert({
    id: randomUUID(),
    userId,
    tool,
    args,
    argsHash: hashArgs(args),
    status: 'pending',
    createdAt: now,
    expiresAt: new Date(now.getTime() + TTL_MS),
    approvedAt: null,
    authenticatedAt: null,
  });
}

/**
 * Atomically claim an approved request for execution. Returns null unless the
 * approval exists, belongs to this user, targets this tool, was approved for
 * exactly these arguments, and has not expired or been used.
 *
 * The status transition must be atomic. Two concurrent tool calls racing on the
 * same approval must not both win.
 */
export async function consumeApproval(
  id: string,
  userId: string,
  tool: SensitiveTool,
  args: Record<string, unknown>,
): Promise<Approval | null> {
  return db.approvals.updateOneWhere(
    {
      id,
      userId,
      tool,
      argsHash: hashArgs(args),
      status: 'approved',
      expiresAt: { gt: new Date() },
    },
    { status: 'consumed' },
  );
}
  

Five conditions on that update, and every one of them is load bearing:

  • id and userId together stop one user's agent from redeeming another user's approval
  • tool stops an approval for rotate_api_keys being spent on delete_environment
  • argsHash stops an approval for the staging environment being spent on production
  • status: 'approved' makes it single use
  • expiresAt closes the window on an approval the user granted and then walked away from

The argument binding is the one people skip, and it is the one that matters most. Without it, an agent can request approval for something harmless, wait for the user to click approve, and then redeem that approval for a different call entirely. The user approved a decision they were never shown.

Step 4: Make the sensitive tool ask instead of act

Here is the shape that makes this work with a language model in the loop. The tool does not throw an error. It returns a calm, structured instruction telling the agent exactly what to do next, because whatever you return here is what the model reads and acts on.

  
// src/tools.ts
import type { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
import { createApproval, consumeApproval } from './approvals.js';
import { SENSITIVE_TOOLS } from './sensitive.js';

const APP_URL = process.env.APP_URL!;

export function registerTools(server: McpServer, ctx: { userId: string }) {
  server.registerTool(
    'delete_environment',
    {
      description:
        'Permanently delete an environment and all of its data. Requires human approval: ' +
        'call once to request approval, then call again with approvalId once the user has approved.',
      inputSchema: z.object({
        environmentId: z.string(),
        approvalId: z.string().optional(),
      }),
    },
    async ({ environmentId, approvalId }) => {
      const args = { environmentId };

      // Second call: the user has approved, so redeem it.
      if (approvalId) {
        const approval = await consumeApproval(approvalId, ctx.userId, 'delete_environment', args);

        if (!approval) {
          return {
            content: [
              {
                type: 'text',
                text:
                  'That approval is not valid for this request. It may have expired, already ' +
                  'been used, or been approved for different arguments. Request a new approval.',
              },
            ],
            isError: true,
          };
        }

        await platform.deleteEnvironment(environmentId);

        return {
          content: [{ type: 'text', text: `Environment ${environmentId} has been deleted.` }],
        };
      }

      // First call: pause and ask for a human.
      const approval = await createApproval(ctx.userId, 'delete_environment', args);
      const { label } = SENSITIVE_TOOLS.delete_environment;

      return {
        content: [
          {
            type: 'text',
            text: [
              `This action requires approval from the account owner: ${label}.`,
              '',
              'Ask the user to open this link and confirm. They will be asked to verify their',
              'identity before approving.',
              '',
              `${APP_URL}/approvals/${approval.id}`,
              '',
              'Do not retry automatically. Once the user says they have approved, call this tool',
              `again with approvalId="${approval.id}" and the same environmentId.`,
              'The approval expires in 5 minutes.',
            ].join('\n'),
          },
        ],
      };
    },
  );
}
  

Two details worth copying.

"Do not retry automatically." Without it, some models will call the tool again immediately, get the same pending response, and loop until they hit a turn limit. Telling the model to wait for the human is the difference between a clean pause and a runaway.

The approval id goes in as a tool argument on the retry. This keeps the whole exchange inside the MCP protocol and stateless on your side. No session affinity, no server-side pending call to hold open.

Step 5: Build the approval page

This is where the actual security happens, and it is deliberately unremarkable: an ordinary authenticated page in your web app that shows a human what is about to happen and asks them to prove who they are.

  
// app/approvals/[id]/page.tsx
import { notFound, redirect } from 'next/navigation';
import { withAuth, checkRecentAuth } from '@workos-inc/authkit-nextjs';
import { SENSITIVE_TOOLS } from '@/lib/sensitive';
import { ApproveForm } from './approve-form';

export default async function ApprovalPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const { user } = await withAuth({ ensureSignedIn: true });

  const approval = await db.approvals.findById(id);

  // Do not distinguish "does not exist" from "belongs to someone else".
  if (!approval || approval.userId !== user.id) notFound();

  if (approval.status !== 'pending' || approval.expiresAt < new Date()) {
    return <Expired status={approval.status} />;
  }

  const { maxAge, label } = SENSITIVE_TOOLS[approval.tool];

  // The gate. If the user has not authenticated inside the window, send them
  // through AuthKit and bring them right back here.
  const { isStale } = await checkRecentAuth({ maxAge });

  if (isStale) {
    redirect(`/step-up?returnTo=${encodeURIComponent(`/approvals/${id}`)}&maxAge=${maxAge}`);
  }

  return (
    <main className="mx-auto max-w-lg p-8">
      <p className="text-sm text-gray-500">An AI assistant is requesting permission to:</p>
      <h1 className="mt-2 text-xl font-semibold">{label}</h1>

      <dl className="mt-6 rounded border border-gray-200 p-4 text-sm">
        {Object.entries(approval.args).map(([key, value]) => (
          <div key={key} className="flex justify-between gap-4 py-1">
            <dt className="text-gray-500">{key}</dt>
            <dd className="font-mono">{String(value)}</dd>
          </div>
        ))}
      </dl>

      <p className="mt-4 text-sm text-gray-500">
        Requested {approval.createdAt.toLocaleTimeString()}. Expires in 5 minutes.
      </p>

      <ApproveForm approvalId={id} />
    </main>
  );
}
  

checkRecentAuth reads the auth_time claim from the access token and reports whether the user's last active authentication falls inside your window. It never redirects on its own, so the page decides. It also fails closed: a token with no usable auth_time reports as stale, so a stale session cannot slip through on a technicality.

The /step-up route is the one from the previous tutorial, calling getSignInUrl({ maxAge, returnTo }). AuthKit forwards maxAge as OIDC max_age and challenges the user with whatever factor they actually use, then handleAuth returns them to this page with a fresh auth_time.

Extend that route to accept a per-action window:

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

export const GET = async (request: NextRequest) => {
  const params = request.nextUrl.searchParams;

  const requested = params.get('returnTo') ?? '/';
  const returnTo = requested.startsWith('/') && !requested.startsWith('//') ? requested : '/';

  const parsed = Number(params.get('maxAge'));
  const maxAge = Number.isInteger(parsed) && parsed >= 0 && parsed <= 900 ? parsed : 0;

  return redirect(await getSignInUrl({ maxAge, returnTo }));
};
  

Both query parameters are attacker controlled, so both are validated. returnTo has to be a relative path or you have an open redirect on an auth route. maxAge is clamped to a sane ceiling so nobody can widen your freshness window by editing a URL.

Step 6: Record the approval

  
// app/approvals/[id]/actions.ts
'use server';

import { checkRecentAuth, withAuth } from '@workos-inc/authkit-nextjs';
import { SENSITIVE_TOOLS } from '@/lib/sensitive';

export async function approve(approvalId: string) {
  const { user } = await withAuth({ ensureSignedIn: true });

  const approval = await db.approvals.findById(approvalId);
  if (!approval || approval.userId !== user.id) return { ok: false };
  if (approval.status !== 'pending' || approval.expiresAt < new Date()) return { ok: false };

  // Check again here. The page-level check decided what to render; this one
  // decides what happens. A user could sit on the page past the window.
  const { isStale, authenticatedAt } = await checkRecentAuth({
    maxAge: SENSITIVE_TOOLS[approval.tool].maxAge,
  });

  if (isStale) return { ok: false, reason: 'stale' as const };

  await db.approvals.update(approvalId, {
    status: 'approved',
    approvedAt: new Date(),
    authenticatedAt,
  });

  return { ok: true };
}

export async function deny(approvalId: string) {
  const { user } = await withAuth({ ensureSignedIn: true });
  await db.approvals.updateWhere({ id: approvalId, userId: user.id }, { status: 'denied' });
  return { ok: true };
}
  

Checking freshness twice is not redundant. The page render decided what to show; this server action decides what actually happens. Between the two, a user can leave the tab open through lunch. Only the second check is a security control, and it is the one that has to be right.

Storing authenticatedAt on the record gives you the audit answer later: not just that someone approved, but when they last proved it was them.

Step 7: Watch it work

Point an MCP client at your server, authorize it, then ask for something destructive.

  
User:  delete the staging-old environment

Agent: [calls delete_environment { environmentId: "env_staging_old" }]

       This action requires approval from the account owner. Please open
       https://app.example.com/approvals/8f3c... and confirm. You will be
       asked to verify your identity.

User:  [opens the link, gets challenged by AuthKit, sees exactly
        "Delete an environment and all of its data / environmentId: env_staging_old",
        clicks approve]

       approved

Agent: [calls delete_environment { environmentId: "env_staging_old",
                                   approvalId: "8f3c..." }]

       Environment env_staging_old has been deleted.
  

Worth testing the failure paths too, because they are the whole point:

  • Approve, wait past the TTL, then retry. The consume fails and the agent is told to request a new approval.
  • Approve, retry twice. The second redemption fails because the status is now consumed.
  • Approve for env_staging_old, then hand craft a retry with a different environmentId. The argsHash no longer matches and the consume returns null.
  • Take a valid approvalId and try to redeem it from a second user's agent. The userId predicate rejects it.

If all four fail correctly, the binding is doing its job.

What about MCP elicitation?

The 2026 MCP spec includes elicitation, which lets a server ask the client to collect structured input from the user mid tool call. It is a natural fit for confirmation, and where clients support it you can use it to render the "are you sure" step inside the chat instead of sending the user to a link.

It does not replace the step-up. Elicitation confirms intent, which is valuable and worth adding on top. It does not verify identity, because the answer comes back through the same client and the same channel that made the request. If your threat model includes a compromised session or a leaked token, the thing that helps is a fresh authentication ceremony in a context the agent does not control. Use elicitation to make the flow feel better and the browser step-up to make it actually mean something.

Designing for models, not just for users

A few things that are specific to having a language model in the loop and worth knowing before you ship.

  • Say what to do next, in the tool response. The model is your retry logic. Vague errors produce creative workarounds, including trying a neighbouring tool that you forgot to gate.
  • Say what not to do. "Do not retry automatically" prevents loops. Add it.
  • Do not leak the approval id into anything the model should not act on. It is a capability. Short TTL, single use, bound to arguments, and never valid for a different user.
  • Assume the model will paraphrase. Whatever your tool returns gets summarized for the user. The link and the identifier need to survive that, which is why they go on their own lines and why the approval page restates the operation in full. The page is the source of truth, not the chat.
  • Never trust a user id from an argument. It comes from sub on the verified token. Always.

Where this goes next

The out of band approval is the right build today, and it stays useful even after the ecosystem catches up, because a browser is where you can show a human exactly what is about to happen. When RFC 9470 challenges land in MCP clients and max_age becomes available on the Connect authorization endpoint, you can add a fast path for clients that support it, keeping the approval page as the fallback everyone else gets.

The design does not change either way. Identify the operations that deserve a human, bind approval to the exact operation, and require a fresh authentication before anything runs. Agents make that discipline more urgent, not different.

Start with your single most destructive tool. The second and third take five minutes once the approvals table exists.

Reference: MCP authentication with AuthKit, reauthentication docs, RFC 9470.