<- blog

When a 403 Becomes an Agent Control Loop

Fresh Cloudflare and Vercel changes show how agents should turn API denials into narrow approvals without exposing the credential they receive.

#ai-agents#security#oauth#api

Three fresh platform changes describe the beginning, middle, and end of an authorization loop for agents.

On 21 August, Cloudflare added a documentation_url to most API 403 Forbidden responses. The link points to the denied endpoint's documentation, where a developer, administrator, or agent can inspect the roles and permissions that endpoint accepts. Cloudflare explicitly describes third-party approval workflows as one use for the new context.

One day earlier, Cloudflare made optional OAuth scopes generally available. An OAuth client can classify configured scopes as required or optional; a user can decline optional permissions during consent; and the application receives a token containing only the granted subset. Also on 20 August, Vercel explained how v0 lets generated code use Snowflake without exposing the user's OAuth token: a proxy outside the sandbox resolves the real credential, constrains its destination, and injects it only into protocol-defined authentication fields.

Together, these changes support a safer pattern than “give the agent more access and retry.” A denial can identify the missing capability. Consent can narrow the proposed grant. A broker can keep the resulting credential out of generated code.

The repeated angle to avoid

The ten most recent posts here covered remote credential containment, cross-border payment routing, MCP traffic detection, ECH and certificate evidence, deployment authentication, Shopify checkout fields, plugin trust, agent context copies, form acceptance, and server-side agent conversion tracking. Older overlapping posts covered runtime-issued credentials, permission budgets, credential revocation, OAuth callbacks, MCP authorization, and agent audit trails.

The weak version of this article would repeat the old X needs Y formula: agents need least-privilege OAuth. That is true but too generic to implement. The sharper thesis is that self-describing denials turn authorization into a control loop, and each transition in that loop needs different evidence. A documentation link is not a permission recommendation, a requested scope is not a granted scope, a granted token is not safe to expose to generated code, and a successful retry is not proof that the grant was proportionate.

Fresh evidence and background context

The source map separates this week's implementation evidence from the durable protocol model:

Source Freshness What it contributes
Cloudflare contextual 403 responses 21 August 2026 Endpoint-specific documentation_url, role information, and the explicit approval-workflow use case
Cloudflare task-based OAuth consent 20 August 2026 Required versus optional scopes, request-specific evaluation, user deselection, partial grants, and the requirement to inspect the granted set
Cloudflare OAuth authorization documentation Updated 20 August 2026 All requested permissions are selected by default, optional permissions can be changed individually or by template, and authorizations can be revoked
Vercel's v0 Snowflake credential boundary 20 August 2026 Server-side credential resolution, workload identity, destination derivation, structural injection, fail-closed parsing, and post-deploy service identity
OAuth 2.0 Token Exchange, RFC 8693 Background, January 2020 The distinction between delegation and impersonation, plus narrower downstream tokens scoped by resource, audience, and capability

The new information surplus is the joined control loop. Cloudflare's 403 tells a caller where to learn about a denial. Its consent change lets a user narrow a grant. Vercel's proxy shows how untrusted code can use an approved capability without receiving the reusable user token. None of those sources alone specifies how an operator should preserve the decision between those points.

A 403 is evidence about one attempted operation

An HTTP 403 Forbidden says the server understood the request but will not authorize it under the current conditions. It does not say that broader access is appropriate, that a human intended the operation, or that permission is the only thing preventing success.

The new Cloudflare response makes the denial more useful:

{
  "success": false,
  "errors": [
    {
      "code": 10000,
      "message": "Forbidden",
      "documentation_url": "https://developers.cloudflare.com/api/resources/workers/subresources/beta/subresources/workers/methods/list"
    }
  ],
  "messages": [],
  "result": null
}

That URL removes guesswork about the endpoint. It can tell an agent which permissions the endpoint accepts and give an approval system a stable reference. It still leaves several questions unanswered:

  • Was the agent authenticated as the intended principal?
  • Did it target the intended account, zone, repository, store, or environment?
  • Is this operation within the task the user approved?
  • Does the existing token have a narrower route that would work?
  • Is the denied endpoint read-only, state-changing, destructive, billable, or data-exporting?
  • Should the correct response be a scope request, a different workflow, or a hard stop?

Treat the documentation as capability metadata, not executable policy. The agent may learn that workers-scripts.write would satisfy an endpoint. Only the operator's task policy can decide whether this run should receive it.

Not every failure belongs in the permission loop

An agent that treats every error as missing access will ask for dangerous permissions and still fail. Classify the response before proposing any grant.

Observation Likely class Safe next step Never infer automatically
401 or invalid token Authentication absent, expired, revoked, or wrong audience Reauthenticate or refresh through the credential service The task needs broader business permissions
403 with trusted endpoint documentation Authenticated request denied by role, scope, resource, or policy Record the denial, inspect current grant and endpoint requirements, then evaluate a minimal delta Every documented role is appropriate
404 on a protected resource Missing object, concealed object, wrong account, or wrong environment Verify target identity without probing broadly More permission will reveal a legitimate target
409 or state conflict Resource is in the wrong state for this transition Re-read authoritative state and reconcile Authorization is the blocker
422 or validation error Request shape or business rule failed Correct bounded input or stop A stronger role will make invalid data valid
429 Rate or quota policy Back off, queue, or request a quota review More OAuth scope increases capacity
5xx or dependency timeout Service or integration failure Retry within budget or recover later A new credential will repair availability

A useful implementation keeps failure_class separate from permission_state. That prevents a retry planner from converting availability, target, validation, and business-state problems into access requests.

The authorization loop has seven distinct states

A safe loop is explicit enough to pause between learning and authority:

operation_attempted
  -> denied_with_context
  -> requirement_interpreted
  -> minimal_delta_proposed
  -> approved | narrowed | rejected
  -> granted_scope_verified
  -> credential_brokered
  -> retried_once
  -> outcome_reconciled

Each transition has a different owner and proof.

1. Operation attempted

Record the intended business action before the request runs: “list Worker scripts in account A” is better than “call Cloudflare.” Include the principal, resource, environment, operation, task ID, and whether the action reads, writes, spends, exports, publishes, or deletes.

2. Denied with context

Keep the HTTP status, provider error code, endpoint, target resource, request ID, current granted scopes if known, and trusted documentation URL. Do not keep the bearer token, full customer payload, or unrelated response data in the approval record.

3. Requirement interpreted

Resolve the documentation only from an allowlisted provider origin and extract the specific role or scope candidates for the denied endpoint. Documentation is external content. An agent reading it should not execute embedded examples, follow unrelated instructions, or expand the target account because a page says an administrator can.

4. Minimal delta proposed

Calculate the difference between current authority and the smallest capability that permits the named operation. The proposal should say what becomes possible beyond the immediate call. A write scope that covers every Worker script is materially larger than permission to deploy one reviewed version, even if both can make the retry succeed.

5. Approved, narrowed, or rejected

The approver is deciding on a capability delta, not dismissing an error dialog. They should be able to remove optional scopes, constrain the resource, require one-time use, add an expiry, switch the action to draft mode, or reject it without breaking unrelated features.

6. Granted scope verified

Read the actual token response or authorization record. Do not assume that requested scopes were granted. Cloudflare's new model deliberately allows the user to return a narrower set.

7. Credential brokered and outcome reconciled

Keep reusable credentials outside untrusted or generated runtime code where the architecture permits it. Retry the denied operation once under the approved boundary, record its result, and reconcile the real business state. A successful HTTP response proves the operation ran; a deployment, refund, lead, order, or DNS change still needs evidence from the system that owns that state.

Optional scopes create three permission sets, not one

Cloudflare's design exposes a distinction OAuth applications often blur:

configured scopes = everything this client is capable of requesting
requested scopes  = what this authorization flow asks for now
granted scopes    = what the user actually approves now

Required and optional classification is evaluated against the scopes requested in the current flow, not every scope configured on the client. If the client is configured for four scopes but requests two, only those two enter consent. If one requested scope is optional and the user deselects it, the issued token contains the remaining grant.

This has two immediate implementation consequences.

First, ask for permissions at the task boundary. An agent that is only reading a zone should not request future Worker writes “in case it needs them later.” Optional access is better than mandatory excess, but not requesting an unrelated capability is better still.

Second, partial grants must be a normal application state. After exchanging the authorization code, inspect the granted scope set and enable only matching features:

type Capability =
  | "zone.read"
  | "workers-scripts.read"
  | "workers-scripts.write"
  | "workers-kv-storage.write";

function buildCapabilitySet(scopeText: string): Set<Capability> {
  return new Set(scopeText.split(" ").filter(Boolean) as Capability[]);
}

function planTask(granted: Set<Capability>) {
  return {
    canInspectZone: granted.has("zone.read"),
    canReadWorker: granted.has("workers-scripts.read"),
    canDeployWorker: granted.has("workers-scripts.write"),
    canMutateKv: granted.has("workers-kv-storage.write"),
  };
}

The real implementation must use the provider's exact token response and scope names. The important behaviour is graceful reduction: hide or disable unavailable operations, explain which task cannot proceed, and preserve the operations the user did approve.

There is a UX trade-off in the release. Cloudflare's documentation says all requested permissions remain selected by default, while users can turn optional permissions off individually or choose Read only or Full access templates. That gives users control, but it does not make the default grant minimal. Client developers still own the first and most important reduction: request only what this task needs.

Separate consent from runtime credential possession

A narrowly approved token can still leak if generated code can read it.

Vercel's Snowflake design is useful because it treats the generated application as untrusted even inside an isolated sandbox. The post points out that sandbox isolation protects the rest of the system from code; it cannot protect a credential placed inside the sandbox from that same code. A readable token can be copied to logs, returned in an API response, embedded in client code, or sent to another host.

The implementation therefore moves credential possession to a proxy outside the sandbox:

user authorizes Snowflake access
  -> server stores or refreshes user-scoped credential
  -> generated code sends an ordinary Snowflake request
  -> sandbox firewall routes the request through a fixed proxy
  -> proxy verifies sandbox OIDC identity and chat binding
  -> proxy derives the connected Snowflake account host server-side
  -> proxy injects the real token only into the defined auth field
  -> Snowflake returns the result through the constrained path

Several details make the boundary stronger than “put a proxy in front”:

  • the sandbox cannot choose an arbitrary destination for a token-bearing request;
  • the Snowflake account host is derived from server-held connection state;
  • the real OAuth token is never written into the sandbox;
  • a public token-shaped placeholder preserves SDK and CLI compatibility without granting access;
  • the proxy writes the token into the Authorization header or the structurally defined login-token field, not arbitrary request text;
  • the request fails closed if the placeholder appears elsewhere, structured parsing fails, or the request body exceeds inspection bounds;
  • after deployment, the application uses its own Snowflake service user rather than continuing to borrow the builder's user token.

The last point is easy to miss. Build-time delegated access and production runtime identity are different grants. A user may approve schema inspection while creating an app; the deployed app should not inherit that user's reusable OAuth token forever.

Why blind credential replacement fails

Vercel describes an earlier proxy implementation that replaced a placeholder string wherever it appeared in a request body. Generated code controls parts of that body. A malicious or prompt-injected SQL query could include the placeholder as a string literal; blind replacement would turn it into the real token, and the database could return the token in query output.

That failure mechanism generalises:

untrusted request data + secret text substitution = attacker chooses injection location

Do not inject secrets by searching arbitrary payloads, templates, prompts, shell commands, URLs, SQL, GraphQL variables, or logs for a marker. Parse the protocol, identify the exact authentication field, overwrite it server-side, reject duplicate or misplaced markers, and constrain the destination independently.

The permission loop therefore has two different least-privilege questions:

  1. Grant least privilege: What capability may this task receive?
  2. Place least privilege: Which component must be able to read the resulting credential value?

OAuth scope reduction answers the first. A credential broker or token exchange can answer the second.

Delegation should remain visible

RFC 8693 distinguishes delegation from impersonation. Under delegation, the actor keeps its own identity while acting on behalf of another subject. Under impersonation, the receiver may treat the actor as the subject within the token's rights context.

For agent operations, delegation is usually the more useful evidence model:

subject: user or service owner who authorized the task
actor: agent workload that performed it
resource: exact downstream API or account
scope: allowed capabilities
purpose: task or approval identifier
lifetime: bounded execution window

The same RFC describes exchanging an input token for one more narrowly scoped to a downstream service, using resource, audience, and scope to describe where and how it will be used. Not every platform implements RFC 8693, and Vercel's Snowflake proxy is its own product-specific design. The transferable rule is narrower: preserve both user and workload identity, bind authority to the destination, and avoid handing the broad upstream credential to every downstream component.

An audit line that says “Paul changed the Worker” loses the agent actor. A line that says “automation changed the Worker” loses the authorizing subject. Keep both when the identity system supports it.

A permission escalation receipt

The operator asset for this loop is a compact receipt that joins the denial to the eventual outcome without storing secrets:

permission_escalation_receipt:
  task_id: task_01K3_example
  requested_by:
    subject: user_482
    actor: quote-site-operations-agent
  attempted_operation:
    provider: cloudflare
    account: production-web-account
    resource: worker/customer-quote
    endpoint: workers_scripts_list
    method: GET
    risk_class: read
  denial:
    occurred_at: 2026-08-21T15:00:00Z
    http_status: 403
    provider_code: 10000
    documentation_url: trusted-provider-endpoint-docs
    request_id: provider-request-id
    current_scopes:
      - zone.read
  proposal:
    requested_scope:
      - workers-scripts.read
    rejected_broader_scope:
      - workers-scripts.write
    purpose: inspect_current_deployment_before_drafting_change
    resource_bound: production-web-account
    expires_after: task_completion
    retry_budget: 1
  decision:
    status: narrowed_and_approved
    approved_by: operations-owner
    approved_scope:
      - workers-scripts.read
    approved_at: 2026-08-21T15:04:00Z
  grant:
    actual_scopes:
      - workers-scripts.read
    credential_visible_to_agent: false
    credential_holder: server-side-broker
    destination_allowlist:
      - api.cloudflare.com
  retry:
    attempted_once: true
    provider_status: 200
  outcome:
    state: inspection_completed
    side_effect_created: false
    grant_revoked_or_expired: true

The receipt should be generated by the authorization system, not composed by the agent after the fact. The agent can propose purpose and scope; trusted services should supply current grant, provider response, approver identity, token placement, retry result, and expiry evidence.

Decision rules for a small team

A small operator does not need to build a general authorization platform before connecting one agent. It does need stop rules.

Allow an automatic retry without new consent only when the existing grant already permits the exact operation, the failure is transient or refresh-related, the target is unchanged, and the retry budget is bounded.

Require approval when the proposed delta adds write, deploy, publish, billing, customer-data, export, messaging, security, DNS, credential, or destructive authority; widens the account or environment; changes the actor; or extends credential lifetime.

Reject rather than escalate when the task purpose does not justify the endpoint, the documentation origin is untrusted, the target cannot be identified, the agent requests a role instead of a narrow capability, the provider only offers an administrator-sized grant for a low-value action, or the safe fallback is a human performing the operation directly.

Use a broker when untrusted generated code, third-party plugins, browser scripts, or user-editable templates need to call a service but do not need to inspect the reusable credential. The broker must authenticate the workload, bind the user or service context, derive or allowlist the destination, inject only at a protocol-defined field, bound parsing, redact logs, and fail closed.

Create a separate production identity when a generated app moves from preview or build-time exploration into an independently operating service. Do not turn the builder's delegated user grant into the production application's permanent identity.

Test the negative paths

The happy path proves very little. Exercise the controls that are supposed to stop accidental widening and credential leakage.

Test Expected result
Agent receives a 403 with a trusted documentation URL Denial is recorded; no scope changes automatically
Documentation lists read and write roles Proposal selects only the capability matching the attempted operation
Documentation URL points off the provider allowlist Approval workflow refuses to fetch or trust it
User deselects an optional scope Token's actual grant is narrower; unavailable feature disables cleanly
User rejects the delta Existing approved features continue; denied task stops without repeated prompts
Token refresh fails Broker requests reauthorization; generated runtime never receives the old or new raw token
Generated payload includes the placeholder outside the auth field Broker rejects and logs bounded misuse metadata
Generated code changes the upstream host Destination check rejects before credential injection
Two agents request the same escalation One approval record is reused or deduplicated; no duplicate consent storm
Approved retry receives another 403 Loop stops after its retry budget; it does not request the next broader role
Build becomes a deployed app Runtime uses a dedicated service identity; user's build-time token is absent
Grant expires or is revoked Subsequent calls fail visibly and no cached token silently extends access

The retry-budget test matters. A model can walk a permission graph one denial at a time: ask for read, retry, discover a write endpoint, ask for write, retry, then discover an account-level operation. Bounding the loop to the originally named operation prevents incremental consent from becoming administrator access by exhaustion.

Failure modes worth reviewing

The documentation link becomes an instruction channel

An agent fetches a legitimate endpoint page and follows every code sample, linked setup guide, or broad-role suggestion as if it were operator policy. Parse only capability metadata needed for the denied endpoint, keep provider documentation on an allowlist, and require local policy to authorize any delta.

Optional scopes become default excess

The client marks high-risk scopes optional but requests them on every authorization, and the consent screen selects them by default. Users approve the familiar full set. Request by current task, not future possibility, and use optionality as a second reduction layer rather than the first.

The application assumes the request equals the grant

A user declines write access, but the application renders write controls and fails repeatedly. Read the actual granted scope set after code exchange, persist it with the authorization record, and make partial operation a tested state.

The token is narrow but visible to untrusted code

The OAuth grant covers one service, yet generated code can print, log, or exfiltrate the token. Keep reusable credentials in a broker and give the runtime a workload identity or constrained session, not the upstream secret.

The broker rewrites arbitrary data

A placeholder replacement can occur in SQL, JSON values, URLs, prompts, or other attacker-controlled fields. Inject structurally at the authentication field, reject misplaced markers, and derive the destination outside untrusted input.

The user becomes the invisible actor

Every downstream event is attributed to the user because the agent borrowed the user's token. Preserve workload identity and the on-behalf-of relationship where possible, then log subject, actor, task, resource, scope, and result.

A successful retry justifies permanent authority

The approved scope fixes one incident and remains attached indefinitely. Give task grants an expiry or review date, separate build-time from runtime identity, and verify revocation or expiration in the receipt.

The practical conclusion

Contextual API denials are an operability improvement. An agent no longer has to guess which endpoint rejected it or send an administrator a generic “permission denied” message. Optional scopes make consent more expressive. Credential brokers let generated code use an approved service without holding the reusable user secret.

The dangerous shortcut is to connect those improvements with an automatic escalation button.

Keep the loop typed. Classify the failure. Record the attempted operation and target. Treat linked documentation as reference data. Propose the smallest capability delta. Let the user narrow or reject it. Verify the scope actually granted. Keep the credential outside untrusted code. Retry once against the same operation. Reconcile the downstream result. Expire the authority.

That turns a 403 from a dead end into a useful control point without turning every blocked agent into its own access administrator.

Need technical help?

I'm a software engineer who builds web apps, APIs, and AI tooling. If you've got a project or a problem to talk through, book a free 30-minute call.

Book time with me ->