An agent deleted a document at 22:45. The person who authorised it had signed in at 16:57.
Nobody was at the keyboard. No token had expired. No rule was broken. The access token was valid for another nineteen hours, and it said exactly what it said six hours earlier: this person is allowed to delete documents. The agent read that as permission and acted.
The token was right about identity. It was silent about presence.
In this tutorial, you'll build a records console where a Claude agent runs read-only tools freely, and every destructive action, delete, refund, and deploy, stops at a server-side check. That check asks one question: how long ago did a human actually authenticate? If the answer is too long ago, the server holds the action until the person signs in again. The build uses the Anthropic API for the agent, Kinde for identity, and Convex for state and the audit trail.
By the end, you'll have built:
- A tool registry that separates safe tools from destructive ones, and gives every destructive tool its own freshness window in seconds
- One enforcement seam that every tool call passes through, with JWKS verification and RS256 pinned
- A freshness check built on the OpenID Connect
auth_timeclaim, read from the ID token and verified server-side at the moment of the call - A Claude tool-calling loop that carries the signed-in person's delegated identity and holds no credential of its own
- A complete step-up round trip: the seam holds the action, the person re-authenticates, the agent re-presents the same call, and the seam releases it
- An audit trail that keeps recording when its own storage is unreachable
- A blanket mode that reproduces the failure on the same code, so you can watch the same task slip through
Every number, reason code, and token claim in this tutorial is measured from that running system.
Table of contents
- Why blanket consent fails
- The demo and its three services
- The freshness primitive: auth_time
- Why iat and exp cannot stand in
- Building the seam
- The tool registry
- The Claude agent loop
- Good manners are not enforcement
- The round trip
- The two negative cases
- Blanket against step-up
- Production hardening
- Honest limitations
- Proving the check can fail
- How to map this to your app
- Resources
Why blanket consent fails
The common answer to agent risk is to ask the person to approve each action. Anthropic measured what that produces. In How we contain Claude across products, their engineering team reports:
Our telemetry showed users approved roughly 93% of permission prompts.
And the mechanism behind it:
The more approvals a user sees, the less attention they pay to each, becoming over time much less diligent in their supervision.
A prompt stream trains people to clear prompts. The approval stops being a decision and becomes a reflex, and the one prompt that mattered gets cleared with the rest.
Anthropic's own answer was containment, not step-up authentication. They moved the defence into the environment: ephemeral containers, sandboxes, and virtual machines that limit what an agent can reach. They also built an auto mode that approves safer actions automatically, so the prompt stream gets shorter.
Step-up authentication answers a different question. Containment limits the blast radius. Step-up asks whether the person is still here, right now, for this one irreversible action. The two are complementary:
- Containment handles the many. Most actions become safe by construction and need no prompt.
- Step-up handles the few. It applies high friction to a small set of irreversible actions.
One design rule follows. A read-only tool must never produce a prompt. Every unnecessary prompt spends the attention you need for the prompt that matters.
The demo and its three services
The build is an operations console. An agent works on records: invoices, releases, and documents. It has six tools.
| Tool | Destructive | Freshness window |
|---|---|---|
list_records |
no | none |
get_record |
no | none |
summarize_records |
no | none |
delete_record |
yes | 300s |
refund_payment |
yes | 120s |
deploy_release |
yes | 120s |
Three services do the work. The Anthropic API runs the agent, with the model id read from an environment variable. Kinde is the identity provider, running OpenID Connect with authorization code and PKCE. Convex stores the registry, the records, the run timeline, and the audit trail.
The windows differ by damage. A document delete gets 300 seconds because a backup can restore it. A refund and a production deploy get 120 seconds, because one moves money and the other reaches customers the moment it lands.
The freshness primitive: auth_time
OpenID Connect defines auth_time: the moment the person last authenticated interactively, in seconds since the epoch. It is the only standard claim that tracks a human rather than a token.
Two real tokens from the same sign-in, decoded:
// ID token // Access token
{ {
"sub": "kp_79a5daf465584…", "sub": "kp_79a5daf465584…",
"auth_time": 1786553833, // <-- here // no auth_time
"iat": 1786553834, "iat": 1786553834,
"exp": 1786557434, // +1h "exp": 1786640234, // +24h
"aud": ["d4ef3c61eb19…"], "aud": [],
"iss": "https://devrelstudio.kinde.com" "iss": "https://devrelstudio.kinde.com"
} }
The differences decide the whole design.
| ID token | Access token | |
|---|---|---|
auth_time |
present | absent |
aud |
client id | [] |
| Lifetime | 1 hour | 24 hours |
amr / acr
|
absent | absent |
The access token is the credential a client presents at an API boundary. It carries no evidence of human presence. The ID token carries that evidence and expires twenty-three hours sooner.
The access token lives 24 hours. The evidence of human presence lives 1 hour. Blanket consent ignores that gap. The freshness check closes it.
For twenty-three of those twenty-four hours, an agent holds a valid credential and the system has no fresh evidence that anyone is there.
The discovery document does not mention it
Kinde publishes a discovery document. Its claims list is short:
{
"claims_supported": ["aud", "exp", "iat", "iss", "sub"],
"code_challenge_methods_supported": ["S256"]
}
auth_time does not appear. The ID token carries it anyway.
This is spec-consistent. OIDC Core defines auth_time as an ID Token claim, and claims_supported is informational rather than exhaustive. The practical lesson: read a real token before you design against a provider.
The seam therefore cannot prove freshness from the access token. It verifies two tokens and binds them together.
Why iat and exp cannot stand in
iat and exp look like freshness. They are not. Both move whenever a token is minted, and a token can be minted with no human present.
The build tested this against the live provider. First, a refresh while the ID token was still valid:
{
"authTimeBefore": 1786552841,
"authTimeAfter": 1786552841,
"authTimeMoved": false,
"idTokenChanged": false,
"accessTokenChanged": false,
"verdict": "INCONCLUSIVE — the refresh returned the same ID token"
}
Kinde returned the byte-identical token. Nothing was minted, so nothing was proved. Reporting that as a pass would be a false result, so the check reports it as inconclusive.
The decisive test needs an expired ID token:
{
"authTimeBefore": 1786553833,
"authTimeAfter": 1786553833,
"authTimeMoved": false,
"idTokenChanged": true,
"idTokenWasExpired": true,
"issuedAtMoved": true,
"tokenIdMoved": true,
"verdict": "auth_time held steady across a newly minted ID token"
}
The provider minted a genuinely new ID token:
| Before | After | |
|---|---|---|
jti |
1dd46212-757e-… |
09ba30b7-e842-… |
iat |
16:57:14Z | 18:02:30Z |
exp |
17:57:14Z | 19:02:30Z |
auth_time |
1786553833 |
1786553833 |
The new token reports an authentication that happened 3,936 seconds earlier, about 65 minutes. A token minted seconds ago correctly describes a human who authenticated over an hour before.
That number settles the design. auth_time tracks human authentication, so freshness builds on it. iat and exp track token issuance, so a check that asks "was this token issued recently?" passes with nobody there.
The decision function never reads iat or exp. The comment says why, so nobody adds them later:
/**
* `iat` and `exp` are deliberately absent from this function. Both move when
* a token is refreshed while no human is present, so neither can stand in for
* human presence. Only `auth_time` survives the minting of a new token.
*/
Building the seam
The seam is one function. Every agent tool call passes through it. There is no second path to a tool.
Every path ends in an audit row. There is no early return that skips the trail.
Step 1: verify the access token
The build pins RS256 and reads keys from the provider's JWKS endpoint:
const result = await jwtVerify(token, jwksFor(metadata.jwks_uri), {
algorithms: ALLOWED_ALGORITHMS, // ["RS256"]
issuer: metadata.issuer,
clockTolerance: 5,
});
// `jwtVerify` already refuses any algorithm outside the allow-list. This
// re-reads the header so the pin is visible at the call site and survives
// a future change to how the options are built.
if (result.protectedHeader.alg !== "RS256") {
throw new TokenVerificationError(
"algorithm_not_allowed",
`Token algorithm ${result.protectedHeader.alg} is not RS256.`,
);
}
The pin appears twice on purpose. The allow-list enforces it. The second check keeps it visible, so a later edit cannot drop it silently.
The verifier refuses malformed claims rather than ignoring them:
- No
sub, noexp, or noiat: refused. - An
auth_timethat is not a positive finite number: refused as malformed, not treated as absent. - An
auth_timemore than 60 seconds in the future: refused. A futureauth_timewould make every comparison pass.
Step 2: read the registry, and refuse when you cannot
An unknown tool is denied unknown_tool. A registry that cannot be read is denied registry_unavailable. Without the registry the seam cannot know whether a tool is destructive or what window it carries, and guessing either way is worse than stopping.
Step 3: read freshness from the ID token, bound by subject
if (context.idToken !== undefined && context.idToken !== "") {
try {
const idClaims = await verifyIdToken(context.idToken);
// Binding. Without this, an ID token from any session of any person
// could vouch for freshness on someone else's access token.
if (idClaims.sub !== accessClaims.sub) {
return await finish("deny", "subject_mismatch");
}
observed.authTime = idClaims.auth_time;
observed.amr = idClaims.amr;
} catch {
// The ID token did not verify. Most often it expired.
return await finish("challenge", "id_token_invalid", { ... });
}
} else if (mode === "step-up") {
return await finish("challenge", "id_token_missing", { ... });
}
The access token answers "who is this". The ID token answers "when did a human last authenticate". Without the binding, a valid ID token from any session could vouch for freshness on somebody else's access token.
The build separates two refusals that both fail closed:
-
id_token_invalid: the freshness evidence did not verify. Most often the ID token expired, because it lives one hour and the access token lives a day. This is the common case for a long-running agent. -
auth_time_stale: the ID token verified, butauth_timeis older than the tool's window.
An early version reported both as auth_time_missing. That was wrong: the claim was not missing, the token had expired. The trail now says which happened.
Step 4: decide
The decision is a pure function. Tests drive the whole table with no tokens, no network, and no database.
export function decide(input: DecisionInput): DecisionOutput {
if (!input.destructive) {
return { decision: "allow", reason: "safe_tool" };
}
if (
input.maxAuthAgeSeconds === undefined ||
!Number.isFinite(input.maxAuthAgeSeconds) ||
input.maxAuthAgeSeconds <= 0
) {
// A destructive tool that declares no window cannot be checked. Allowing
// it would mean "no limit"; that reading is the failure being fixed.
return { decision: "deny", reason: "registry_defect", ... };
}
const authAgeSeconds =
input.authTime === undefined ? undefined : input.now - input.authTime;
if (input.approvalMode === "blanket") {
return { decision: "allow", reason: "blanket_mode_freshness_skipped", ... };
}
if (input.authTime === undefined) {
// Freshness that cannot be proved is not freshness.
return { decision: "challenge", reason: "auth_time_missing", ... };
}
const withinWindow =
authAgeSeconds! <= input.maxAuthAgeSeconds + input.clockSkewSeconds;
return withinWindow
? { decision: "allow", reason: "fresh_authentication", ... }
: { decision: "challenge", reason: "auth_time_stale", ... };
}
Three details are worth copying.
A missing window is a defect, not permission. Treating an absent maxAuthAgeSeconds as "no limit" is the failure this design closes. The function denies instead.
Clock skew widens the window, never narrows it. A provider clock slightly ahead of yours must not turn a fresh authentication into a challenge. The build adds a 30-second grace.
Safe tools return before any freshness logic runs. They cannot produce a prompt even by accident.
The challenge response
A held call answers with HTTP 403 and an RFC 9470 challenge:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_user_authentication",
error_description="The last human authentication is older than this action
allows. Re-authenticate to continue.", max_age=120
RFC 9470 defines insufficient_user_authentication for this case: the token is valid, but the authentication behind it does not meet the resource server's requirement. The max_age parameter tells the client what the server needs.
The body carries the same facts plus a re-authentication link:
{
"tool": "delete_record",
"decision": "challenge",
"reason": "auth_time_stale",
"correlationId": "3ad3c1c9-c0cb-4c9c-a0d7-0e3f79b48ba2",
"authAgeSeconds": 1800,
"maxAuthAgeSeconds": 300,
"error": "insufficient_user_authentication",
"reauthUrl": "/api/auth/login?max_age=0&prompt=login&stepUp=1&returnTo=%2F"
}
max_age=0 and prompt=login ask the provider for an interactive sign-in. They are a hint to the provider. They are never proof. The seam re-reads auth_time from the presented token on the retry and decides again.
The tool registry
The registry holds the policy. Each tool declares a name, a destructive flag, and a window.
The build enforces an invariant on every write:
if (tool.destructive) {
if (tool.maxAuthAgeSeconds === undefined) {
violations.push(`destructive tool "${tool.name}" has no maxAuthAgeSeconds`);
} else if (tool.maxAuthAgeSeconds <= 0) {
violations.push(`destructive tool "${tool.name}" has a non-positive window`);
}
} else if (tool.maxAuthAgeSeconds !== undefined) {
violations.push(
`safe tool "${tool.name}" carries maxAuthAgeSeconds; ` +
`safe tools must not have a freshness window`,
);
}
Both halves matter. A destructive tool must carry a window, or there is nothing to compare auth_time against. A safe tool must not carry one. That second rule is approval fatigue written as code: a window on a read-only tool would produce a prompt that never needed to exist, so the registry rejects it.
Windows do real work
Two measurements from the build show the windows are not decoration.
At one instant, on one token, two destructive tools got different answers:
| Tool | Window | Auth age | Decision |
|---|---|---|---|
refund_payment |
120s | 203s | challenge |
delete_record |
300s | 292s | allow |
Same person, same session, seconds apart. The refund was held because moving money has a tighter window. The delete passed because a document delete gets more room.
Then the same tool changed answer with nothing else changing:
| Tool | Window | Auth age | Decision |
|---|---|---|---|
delete_record |
300s | 292s | allow, executed |
delete_record |
300s | 399s | challenge |
Time alone closed the window. The window is measured at the moment of the call, every call.
The Claude agent loop
The agent holds no credential. It calls the same public endpoint every other client uses, and it forwards the signed-in person's session unchanged:
const response = await fetch(new URL("/api/tools/invoke", appConfig().siteUrl), {
method: "POST",
headers: {
"Content-Type": "application/json",
// The person's session, forwarded unchanged. This is the whole of the
// agent's authority.
cookie: cookieHeader,
},
body: JSON.stringify({ tool, args: input, correlationId }),
cache: "no-store",
});
The agent never imports the tool executors. If the seam refuses, the agent is refused.
The tempting shortcut is to give the agent its own API key so it "just works". That shortcut is the failure this design closes. A service account decouples the agent's authority from the person's, and auth_time becomes meaningless.
Three responses, handled differently
- allow: run the tool, feed the result back, continue.
- challenge: stop. Freeze the conversation. Show the person a re-authentication link.
- deny: stop and report. A denial is not something re-authentication fixes, so the agent offers no link.
The challenge path is where agents go wrong. Blind retry turns a step-up challenge into a spin, because only the human can clear it. Tool substitution is worse, because it looks like helpfulness: an agent that answers "delete was refused" by reaching for another mutating tool has routed around the control that just fired.
The build blocks both. The system prompt states the rule:
A destructive tool may be held by the server until the person has authenticated
recently. If that happens you will be told so in the tool result, and the run
will end so the person can re-authenticate. That is a normal outcome, not an
error to work around: do not retry a held call, and do not substitute a
different tool to achieve the same effect.
The loop makes it structural. A challenge returns out of the loop, so the model never gets another turn in which to try something else.
Good manners are not enforcement
Two runs in the build produced the same visible outcome and mean completely different things.
Run one. The prompt asked the agent to find the superseded document and delete it. The dataset had none, because an earlier run had already deleted it. The agent listed the documents, read them, and refused:
I reviewed the documents and did not delete anything, because nothing matches what you asked me to find. Neither document is superseded. Both are status
active, and each summary points the other way — DOC-3301 is the sole copy of a working model, and DOC-3302 is still load-bearing for three open action items.
No destructive tool was called. The audit trail shows only safe calls.
Run two. Same prompt, record restored. The agent identified the right document, called delete_record, and the seam refused it with auth_time_stale.
Both runs ended with nothing deleted. Only the second is a security control.
The model choosing not to act is good behaviour. It depends on the prompt, the data, and the model's judgement on the day. It is not a boundary. The server refusing to let it act is enforcement, and it holds regardless of what the model decided or which model you swapped in.
When you test your own system, force the destructive call to reach the boundary. If it never reaches the seam, you have tested nothing about the seam.
The round trip
The full sequence, under one correlationId:
The resume re-presents, it does not re-plan
When the seam holds a call, the build stores the conversation and the exact held call:
await convex().mutation(api.runs.pause, {
runId: ctx.runId,
haltedReason: seam.body.reason ?? "step_up_required",
challengeAuthTime: seam.body.authTime,
pausedState: {
messages,
toolUseId: request.id,
toolName: request.name,
toolInput: request.input,
},
});
On resume, the agent sends that same call to the seam before the model gets another turn:
if (pending !== undefined) {
const seam = await callSeam(
pending.toolName,
pending.toolInput,
ctx.correlationId,
ctx.cookieHeader,
);
...
}
This is a security property, not a convenience. If the resume let the model re-plan, a released run would not be the task the person approved. It could become a different, larger action wearing the same correlationId. Same tool name, same input, same tool_use_id, or nothing.
The resume route decides nothing
after(async () => {
await resumeAgent({ runId, userId: session.subject, cookieHeader, observedAuthTime });
});
return NextResponse.json({ runId, accepted: true }, { status: 202 });
No path through the route releases something the seam would refuse. The auth_time the route reads serves the audit narrative only, and it comes from a verified token, never from the caller.
The two negative cases
A control is only as good as what it refuses. Two refusals carry the whole design.
Case 1: a refresh does not release the action
Before any re-authentication, the build ran a refresh_token grant against the live provider, then retried the held delete.
The refresh minted a new, valid ID token. The previous one had expired, so this was a real minting, not a cached response.
The retry was refused.
The reason code tells the story precisely:
| Attempt | Reason | Auth age | Window |
|---|---|---|---|
| Original halt | id_token_invalid |
— | 300s |
| After refresh | auth_time_stale |
4409s | 300s |
The reason moved from id_token_invalid to auth_time_stale. That change is the finding:
The refresh repaired the token's validity. It did not repair its freshness.
A machine-to-machine exchange restored a well-formed, verifiable, unexpired credential. The destructive action stayed held, because no human had been present. This is the property the whole design rests on, measured rather than assumed.
If your freshness check reads exp, this case passes and the action runs.
Case 2: re-authentication does not bless the session
The second refusal was not planned. A genuine interactive re-authentication happened. Then about five minutes of other work passed before the retry fired.
The retry was refused:
| Attempt | Reason | Auth age | Window |
|---|---|---|---|
| After real re-auth, delayed | auth_time_stale |
352s | 300s |
The person had re-authenticated correctly. The window had closed again.
This rules out a weaker reading of the control: that re-authenticating grants the session a blessing that later calls ride on. It does not. The window is measured at the moment of each call. A correct re-authentication that has since aged out is refused exactly like one that never happened.
The release
The next re-authentication was followed promptly by the retry:
| Decision | Reason | Auth age | Window |
|---|---|---|---|
| allow | fresh_authentication |
50s | 300s |
The delete executed. The record changed.
Seven audit rows carry that run: three safe calls, three challenges, one release. All share one correlationId, so the story stays readable months later.
Blanket against step-up
The build ships two modes so the failure and the fix run side by side on the same code.
APPROVAL_MODE is read from the deploy environment and from nowhere else:
export function approvalMode(): ApprovalMode {
const raw = process.env.APPROVAL_MODE;
if (typeof raw !== "string") return "step-up";
return raw.trim().toLowerCase() === "blanket" ? "blanket" : "step-up";
}
Three properties are deliberate. The variable has no NEXT_PUBLIC_ prefix, so Next.js never inlines it into the browser bundle. The seam never reads it from a header, query string, cookie, or tool argument, so a caller cannot set it. And only the exact string blanket selects the permissive mode: unset, empty, misspelled, or hostile values all resolve to step-up, so a misconfiguration fails towards enforcement.
Running the same task in each mode, with an authentication about five hours old:
| Mode | Tool | Auth age | Decision | Executed |
|---|---|---|---|---|
| blanket | refund_payment |
~5h | allow (blanket_mode_freshness_skipped) |
yes |
| blanket | delete_record |
~5h | allow (blanket_mode_freshness_skipped) |
yes |
| blanket | deploy_release |
~5h | allow (blanket_mode_freshness_skipped) |
yes |
| step-up | delete_record |
~5h | challenge (auth_time_stale) |
no |
In blanket mode the agent refunded an invoice, deleted a document, and deployed a release to production. Same tokens, same tools, same code. Only the deploy environment differed.
The number that matters
The headline counter is executedWithoutFreshAuth. It counts destructive calls the seam allowed while the authentication was outside the tool's window, or allowed with no age recorded at all. Counting the missing case as a failure keeps the metric honest.
In step-up mode it stays at 0. In blanket mode it climbs.
One subtlety caught the build's own test. A blanket-mode slip is not automatically an escape. If the person signed in seconds earlier, the check was skipped but nothing escaped a window it would have failed, and the counter correctly stays at zero. The hole only bites when the human is absent. The end-to-end test now ages the session past the window before the blanket task runs, so the escape it measures is real.
Production hardening
Three gaps appeared during the build. Each is easy to ship without noticing.
The audit row that vanished
The first version failed closed when Convex was unreachable. The tool did not run, but no audit row was written. A refusal happened with no record that it had. A control that silently stops recording looks identical to one that stopped working.
The audit write now has three stages:
| Stage | Result |
|---|---|
| Convex, retried with backoff | recorded |
| Local append-only spool file | spooled |
| Neither | lost |
The write never throws, because the seam needs the outcome to decide what to do next:
if (written.durability === "lost" && decision === "allow") {
// Neither the store nor the spool took it. Allowing now would mean an
// action ran with no evidence anywhere that it was permitted.
effectiveDecision = "deny";
effectiveReason = "audit_unavailable";
}
The audit outcome can only make a decision stricter, never looser.
Testing this exposed a second hole. With Convex fully unreachable the seam never reached the audit write: the registry lookup threw first, the route returned 500, and no row was written. Same gap, one step earlier. It would have stayed hidden if the outage had only been simulated at the audit call.
Measured with the sink pointed at an unreachable host:
POST /api/tools/invoke → 403
decision=deny reason=registry_unavailable
.audit-spool.jsonl:
deny registry_unavailable delete_record cid=0020d4ca
GET /api/health:
{"degraded": true, "pendingRows": 1}
After the sink returned, one health probe drained it: {"degraded": false, "pendingRows": 0, "replayedNow": 1}. The refusal happened, was recorded while the store was down, showed as a backlog, and landed in the store on recovery.
The success that reported as a failure
A resume once released a destructive action, executed it, wrote its audit row, and then returned HTTP 500. A trailing Convex write had failed after the work was done. The most dangerous thing a console can say about a delete that happened is "it failed".
Two changes make that impossible. The response precedes the work: both routes validate, return 202 Accepted, and drive the loop in after(). And the run's own state is authoritative: the console reads status, timeline, records, and counters from Convex by subscription, never from the fetch that started the run.
The split between the two trails is deliberate. Timeline writes are best-effort telemetry, and losing one costs visibility. Audit writes are strict and can refuse the call, and losing one costs the security record.
The state that lied
A resume left the run marked halted for its whole duration. The console showed "Held" while a resume was in flight.
The test found it. The end-to-end script waited for the run to settle, read halted immediately, and asserted against the previous outcome. The load-bearing negative assertion passed without the resume having happened.
The fix sets the run to running at the start of a resume, before the seam is consulted. The test now also waits for a new audit row before reading any outcome, because a new row proves the seam ruled.
Honest limitations
Two limitations stand. Both are stated in the project README.
It proves when, not how
The console proves when a person authenticated. It cannot prove how.
The Kinde tenant emits no amr or acr claim on either token, even after the person completes multi-factor authentication. This was checked against Kinde's documentation and against real tokens from a real MFA sign-in.
The ID token carries 19 claims:
at_hash, aud, auth_time, azp, email, email_verified, exp, family_name,
given_name, iat, iss, jti, name, nonce, org_codes, picture, rat, sub,
updated_at
No amr. No acr. The access token carries none either.
To be sure this was the provider's behaviour and not a gap in the build's own claim handling, the session view reports the claim names on each verified token. A claim that is sent but never read looks identical to a claim that is never sent, unless you check.
Kinde does support custom properties in tokens. The build deliberately did not use them here. A custom property is a static value configured against a user. It is not a record of what happened during a sign-in. A property reading "mfa" would be emitted on a password-only login exactly as on an MFA login. That is not a weaker control. It is a false one, and it would read to an auditor as though the second factor had been verified.
The assertion mechanism ships anyway, and ships off:
export function requiredAuthMethods(): string[] {
return optional("STEP_UP_REQUIRED_AMR", "")
.split(",")
.map((value) => value.trim().toLowerCase())
.filter((value) => value !== "");
}
Set STEP_UP_REQUIRED_AMR=mfa and a destructive release must evidence that method, or the seam holds it with amr_unprovable or mfa_required. On this tenant that setting refuses every destructive call, because there is no amr to check. The refusal is correct fail-closed behaviour, but it is not a control this provider can satisfy, so the flag stays empty.
For the same reason acr_values never appears in the challenge header. Demanding an authentication context you cannot verify is a promise you cannot keep.
The audit spool is a floor
If the audit store is unreachable, a decision is retried, then written to a local append-only file, then replayed when the store returns. A decision is never silently lost.
But that file lives on one host and does not survive it. It is the floor, not a durable-queue story. A production deployment wants a queue with its own availability guarantees.
Proving the check can fail
A test that cannot fail is decoration.
One script walks the whole story in a single pass: npm run e2e. It runs 45 assertions across seven steps, and every assertion reads the deployment back rather than the HTTP status.
Two properties are worth copying. It never sets APPROVAL_MODE on a request: the mode belongs to the deployment, so switching it means restarting the server. A request-level override would have been easier and would have destroyed the invariant the script exists to prove. It never mints a session: it drives a real browser, and the operator signs in once and re-authenticates once.
A passing run:
── Step 1: Clean slate escapes start at 0
── Step 2: Step-up, read-only every call allowed, nothing challenged,
no record touched
── Step 3: Blanket, the hole refund executed, record changed,
escapes → 1
── Step 4: Step-up, the halt deploy held, nothing executed
── Step 5: The negative refresh did NOT advance auth_time;
resume refused and recorded
── Step 6: The release auth_time advanced; released exactly
once at fresh_authentication
── Step 7: Reconciliation one correlationId; release did NOT
count as an escape
final counters: safe=1 destructive=3 challenged=2 escapes=0
PASS — 45 assertions across 7 steps.
Then the important part. Inverting the load-bearing assertion, so it claims a refresh should advance auth_time:
✗ ASSERTION FAILED
the refresh did NOT advance auth_time
expected true, got false
FAIL — stopped after 27 passing assertions.
EXIT CODE: 1
It stops at the first failure, names the assertion, prints expected against actual, and exits non-zero.
Run that experiment on your own suite. If you cannot make a security test go red on demand, you do not know what it is testing.
How to map this to your app
The identity provider, the database, and the model are replaceable. Six decisions are not.
1. Classify your tools, and be strict about the safe ones. Split tools into read-only and destructive. Give every destructive tool a window in seconds, tiered by how hard the action is to undo. Forbid windows on safe tools. Enforce both halves in code.
2. Read auth_time from wherever your provider puts it. Decode a real token first. On Kinde it sits on the ID token, and the discovery document does not list it. Verify the token that carries authorization and the token that carries freshness, then bind them by sub.
3. Never use iat or exp as a freshness proxy. Test this against your own provider. Let an ID token expire, run a refresh, and compare auth_time before and after. If auth_time moves, you need a different primitive.
4. Put the check in one place. One function, on the server, at the moment of the call. Not in the client. Not in the prompt. Not spread across six tool implementations.
5. Fail closed, and record before you answer. A missing window, an unverifiable token, an unreadable registry, an unrecordable decision: all refuse. Write the audit row before you return the decision.
6. Handle the challenge properly in the agent. Stop the run. Do not retry. Do not substitute another tool. Store the exact held call and re-present it after re-authentication.
Then test the negatives. Anyone can show a release after a successful sign-in. The design is only worth something if a refresh-only retry stays blocked, and if a correct re-authentication that has aged out is refused too.
Resources
- How we contain Claude across products. Anthropic engineering. Source of the ~93% approval figure and the containment approach.
-
RFC 9470: OAuth 2.0 Step Up Authentication Challenge Protocol. Defines
insufficient_user_authenticationand themax_ageparameter. -
OpenID Connect Core 1.0. Defines
auth_time,max_age, andprompt=login. - Kinde documentation. ID tokens, token customization, and multi-factor authentication.
- Anthropic API tool use. The tool-calling loop the agent runs.
- Convex. The reactive database behind the registry, records, timeline, and audit trail.







Top comments (0)