Skip to main content
Use the OAuth 2.1 Authorization Code flow with PKCE when a browser-based MCP client (Cursor, Claude, and the like) connects to a paid Nevermined agent and the user approves in a browser. It’s the browser-based counterpart to the no-browser device flow — the two grants differ only in how the human approves.
Which credential you receive is chosen by the requested resource, not by the grant (details). To obtain an x402 payment permission — the credential you attach to x402 requests against the agent — request that agent’s resource. If you omit resource and send only a plan_id (a plan-only binding with no delegation), the exchange returns an NVM API key instead. A resource matching the Nevermined API host also returns an API key.

Prerequisites

  • An authenticated user. POST /oauth/authorize runs in the signed-in user’s context — the browser client calls it with the user’s Nevermined API key. Without an authenticated user it fails with BCK.OAUTH.0007.
  • A client_id Nevermined knows. Either a pre-registered connector — onboarded out of band, as for the device flow (no dynamic client registration; an unregistered slug is rejected with BCK.OAUTH.0016) — or an https URL naming a Client ID Metadata Document, which needs no registration for agent-specific grants (see self-registering clients below). The device flow accepts only the former.
  • PKCE is mandatory. Generate a code_verifier and its code_challenge (S256). There is no non-PKCE path.
  • The right tier’s authorize URL. The browser step starts at the authorization_endpoint of the tier you are integrating with — https://nevermined.app/oauth/authorize?network=sandbox or …?network=live — as advertised by that tier’s RFC 8414 document. This is the web app’s consent page, the URL a connector sends the user’s browser to with client_id, redirect_uri, code_challenge and the rest in its query string — keep the network parameter when you add them; it is what makes the consent screen open on the tier your client_id is registered on. It is not the API’s POST /oauth/authorize in Step 1 below, which the signed-in browser client calls on the API host with those same fields in a JSON body (and no network — by then the tier is fixed). The consent URL is the only one of the two an integrator ever configures by hand; if you do, paste it with its query string.
  • Which consent you are asking for. An agent_id on the authorize URL asks for an agent-specific grant (this page’s flow). Without one, the consent is account-level, and its kind is named on the URL: consent_type=commerce for a spend cap the user funds from their Nevermined wallet for paying external services through the Router; nothing (or consent_type=account_access) for a card-backed account mandate. A discovery-driven client — one that took the endpoint from the RFC 8414 document and never saw a consent_type — selects the commerce consent with the standard OAuth scope parameter instead: scope=commerce (space-separated per RFC 6749 §3.3; extra scopes such as offline_access are tolerated and ignored) is treated as consent_type=commerce. An explicit consent_type is kept as long as scope does not contradict it; a contradiction — consent_type=account_access&scope=commerce, or scope=commerce together with an agent_id — is refused with an error rather than resolved silently. A hand-configured provider (LangSmith Fleet’s Agent-Auth, say) keeps stating ?consent_type=commerce on its authorize URL; scope is for clients that cannot. scope is not advertised in the discovery document (Nevermined publishes no scopes_supported — the API layer enforces no scope vocabulary) and never reaches the API: it only selects which consent screen the human sees.

The flow

1

Request an authorization code

From the signed-in browser session, POST to the authorization endpoint with your PKCE challenge. The request is authenticated as the user (their Nevermined API key):
client_id, redirect_uri, code_challenge, and code_challenge_method are required; agent_id, plan_id, state, and resource are optional. resource is optional but load-bearing: the agent’s resource here is what makes the exchange mint an x402 payment permission rather than an API key. Once the user approves the connection, the endpoint returns the code in the response body:
Your client then completes the OAuth hand-off by redirecting the browser to redirect_uri with the code (and your state) appended — plus iss, the RFC 9207 issuer identifier, which equals the issuer of the tier’s discovery document. A denial redirects the same way with error=access_denied, your state, and iss. Validate iss against the issuer you discovered before redeeming the code (details).
2

Exchange the code for the credential

Prove possession of the PKCE verifier and exchange the code:
Response:
3

Pay the agent

Because you requested the agent’s resource, the access_token is an x402 payment permission for the agent+plan you were authorized against (unlike an NVM API key, it is a JWT and is not environment-prefixed). Use it on your x402 requests to that agent. See the MCP integration for how a paywalled MCP server consumes it.
The connection the user approves is recorded as an AgentBinding — the consent receipt. The client_id is the connector fronting the connection (Cursor, Claude, LangSmith Fleet), which is distinct from the agent the consent is for. Users can review and revoke these at any time from the Connected agents surface.
Both the authorization_endpoint and the authorization_code grant are advertised in the RFC 8414 metadata — per tier, with the tier on the endpoint (?network=sandbox|live) — as is authorization_response_iss_parameter_supported: true, the promise that every authorization response carries iss. As with the device flow, discovery doesn’t substitute for a client_id Nevermined accepts — a pre-registered connector, or a metadata-document URL for agent-specific grants; and a connector’s registration (and a self-registering client’s commerce trust) is per tier, so a client known to Sandbox is “not authorized” on Live and vice versa.
Two grants ask for authority over the user’s whole account rather than one agent, and both omit agent_id: A connector is authorized for each of these separately when it is registered. Being enabled for commerce does not enable account_access, and vice versa — an assistant that pays external services from the user’s wallet never gains the ability to request a card mandate by asking for the other consent type. Registration carries an accountLevelConsentTypes list (["commerce"], ["account_access"], or both); a connector registered before the list existed keeps both. Requesting a consent type your connector is not authorized for is refused with BCK.OAUTH.0035 (403), whose params.consentType names the refused type — distinct from BCK.OAUTH.0018 (your connector cannot request account-level authority at all, so agent_id is required) and BCK.OAUTH.0025 (it can, but is not yet verified). The consent page checks this before it renders the spend form and tells the user which consent type was refused, so the human is never walked through a mandate the API would reject at approval. Commerce-only connector. The typical shape for an AI assistant that only needs to pay external services: registered with accountLevelConsentTypes: ["commerce"], it starts the consent at the tier’s authorization_endpoint with consent_type=commerce (and the commerce_* spend-cap fields — no card, no agent_id). A request from that same connector with neither consent_type=commerce nor an agent_id is an account_access request, and is refused with BCK.OAUTH.0035; its agent-specific grants (an agent_id plus plan_id) are unaffected.

Self-registering clients: identity from the document, trust from the operator

Not every connector is registered by hand. A client that follows the MCP authorization spec — ChatGPT, Claude Code, and hosts built on the same SDKs — presents an https URL as its client_id (a Client ID Metadata Document, CIMD). Nevermined fetches that URL, verifies the document names itself, and takes the client’s name and redirect URIs from it. Ownership of the URL’s domain is the identity proof, so there is nothing to register and nothing for an operator to type: every self-registering client can run the agent-specific flow on this page out of the box. What the document can never do is make its client trusted. Account-level grants — commerce, account_access — need allowAccountLevel and trustTier: "verified", and a self-asserted document cannot grant itself those; a self-registering client that asks is refused with BCK.OAUTH.0018, exactly as an unknown static connector would be. Trust comes from the operator instead, through the same registry static connectors live in: an entry whose clientId is the client’s client_id URL carries only allowAccountLevel, trustTier and accountLevelConsentTypes, and no redirect URIs (an entry that lists them is refused as malformed — redirects always come from the document). The next time that URL starts a consent, its document-derived identity is overlaid with the configured trust. Two rules differ from a static entry, both because the identity is self-asserted: accountLevelConsentTypes must name every consent type the client is granted (omitting it grants nothing, never “both”), and an entry removed from the registry stops granting at the operator’s next restart — there is no lingering trust. Out of the box on every Nevermined tier, at ["commerce"] only: To enable another self-registering assistant for commerce, its operator adds the URL it presents as client_id to the registry with allowAccountLevel: true, trustTier: "verified" and accountLevelConsentTypes: ["commerce"] — one line, no redirect list, no code. The URL is whatever the assistant sends as client_id — read it off the authorize link the assistant opens (the client_id query parameter) or from the vendor’s published client metadata; an unlisted client is refused without a log line naming it. Because a self-registering client’s name is its own claim, the consent screen shows the host of its client_id URL beside that name — ChatGPT with chatgpt.com — and POST /oauth/connect/details returns it as clientOrigin (https://chatgpt.com; null for a static connector). A document that cannot be fetched, or that fails validation, is refused with BCK.OAUTH.0031 on both the consent page and /oauth/authorize.