DEV Community

Cover image for How to Require Step-Up Auth Before a Claude Agent's Destructive Actions With Kinde
Shola Jegede
Shola Jegede Subscriber

Posted on

How to Require Step-Up Auth Before a Claude Agent's Destructive Actions With Kinde

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_time claim, 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

  1. Why blanket consent fails
  2. The demo and its three services
  3. The freshness primitive: auth_time
  4. Why iat and exp cannot stand in
  5. Building the seam
  6. The tool registry
  7. The Claude agent loop
  8. Good manners are not enforcement
  9. The round trip
  10. The two negative cases
  11. Blanket against step-up
  12. Production hardening
  13. Honest limitations
  14. Proving the check can fail
  15. How to map this to your app
  16. 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"
}                                        }
Enter fullscreen mode Exit fullscreen mode

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.

mermaid 1

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"]
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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.
 */
Enter fullscreen mode Exit fullscreen mode

Building the seam

The seam is one function. Every agent tool call passes through it. There is no second path to a tool.

mermaid 2

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.`,
  );
}
Enter fullscreen mode Exit fullscreen mode

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, no exp, or no iat: refused.
  • An auth_time that is not a positive finite number: refused as malformed, not treated as absent.
  • An auth_time more than 60 seconds in the future: refused. A future auth_time would 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", { ... });
}
Enter fullscreen mode Exit fullscreen mode

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, but auth_time is 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", ... };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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`,
  );
}
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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:

mermaid 3

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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,
  );
  ...
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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.

The console holds delete_record. The banner names the tool and reports the authentication age, 26m 20s, against the tool's window of 5m 0s. The escapes counter stays at zero.

The same run, one correlationId, top to bottom. The seam holds delete_record at auth_time_stale, reporting an authentication age of 3h 12m against a window of 5m 0s. The person re-authenticates. The seam then allows the same call at fresh_authentication, age 3s against the same 5m 0s window, and the delete executes. DOC-3303 shows as deleted in the records panel.

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";
}
Enter fullscreen mode Exit fullscreen mode

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.

Step-up mode. The escapes counter reads zero, and DOC-3303 shows as deleted in the live records panel after the released action.

Blanket mode, same code. The seam allows refund_payment with the reason

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";
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 !== "");
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)