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.
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
checkRecentAuthand the/step-uproute 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:
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-nextjs4.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
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.
And the server itself:
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.
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.
Five conditions on that update, and every one of them is load bearing:
idanduserIdtogether stop one user's agent from redeeming another user's approvaltoolstops an approval forrotate_api_keysbeing spent ondelete_environmentargsHashstops an approval for the staging environment being spent on productionstatus: 'approved'makes it single useexpiresAtcloses 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.
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.
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:
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
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.
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 differentenvironmentId. TheargsHashno longer matches and the consume returns null. - Take a valid
approvalIdand try to redeem it from a second user's agent. TheuserIdpredicate 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
subon 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.