---
title: "OAuth & consent"
description: "How an agent becomes a signed-in user — the 401 challenge, Better Auth's OAuth provider inside Convex, the token exchange that mints short-lived Convex JWTs, scopes, rate limits, and what production needs."
canonical_url: "https://nuxt-backend.dev/docs/agents/mcp/oauth-and-consent"
---
# OAuth & consent

> How an agent becomes a signed-in user — the 401 challenge, Better Auth's OAuth provider inside Convex, the token exchange that mints short-lived Convex JWTs, scopes, rate limits, and what production needs.

An agent never gets an API key. It runs the OAuth authorization-code flow against **your** app, a human signs in and consents to a set of scopes, and from then on every tool call happens as that user, with those scopes, through a Convex JWT that lives five minutes.

## The flow, end to end

<steps level="3">

### The agent hits `/mcp` with no token

The gate answers `401` with the RFC 9728 challenge — a JSON-RPC error body mirroring Better Auth's own shape, plus the header MCP clients look for:

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://your-app.com/.well-known/oauth-protected-resource"
Access-Control-Expose-Headers: WWW-Authenticate
Content-Type: application/json

{"jsonrpc":"2.0","error":{"code":-32000,"message":"Unauthorized: authentication required",
 "www-authenticate":"Bearer resource_metadata=\"…\""},"id":null}
```

That is the handshake, not a failure. A client that stops here doesn't implement the flow.

### It reads the two discovery documents

`/.well-known/oauth-protected-resource` names the app origin as the protected `resource`, advertises the [scopes](#scope-semantics), and points at the authorization server — the auth base on the app origin (`/api/auth` by default), which the base module proxies to Convex. `/.well-known/oauth-authorization-server` then returns Better Auth's own RFC 8414 document (proxied from the Convex site URL and cached for five minutes) with the authorize, token, registration and JWKS endpoints.

### It registers itself and sends the user to sign in

The OAuth provider is Better Auth's **mcp plugin**, enabled by default inside `setupAuth`. Agents obtain access through dynamic client registration plus the authorization-code flow with PKCE. The person lands on your app's login page (`/login` by default; the package is passwordless), and signing in sends the browser back to the provider, which opens the [consent page](#consent).

### It exchanges its token on every request

The agent now holds an **opaque** Bearer. Convex functions authenticate with JWTs, so the Nitro gate bridges the two: it POSTs the Bearer to `POST {CONVEX_SITE_URL}/mcp/exchange`, which

1. looks the token up in the provider's token store (unknown or expired → `401`),
2. spends one token of the `mcp` rate limit, keyed `clientId:userId` (over budget → `429`),
3. loads the user — a deleted or banned account fails like an invalid token,
4. resolves the user's workspace the way a fresh session would (first membership), so agent spend lands on the same billing entity as the web app's,
5. mints a Convex JWT whose claims mirror a session JWT: `name`, `email`, `emailVerified`, `role`, `activeOrganizationId`, `scopes`, `clientId`, `sub`, `iss` (the Convex site URL), `aud: 'convex'`, and a **five-minute** `exp`.

```json
{ "token": "<jwt>", "expiresIn": 300,
  "session": { "userId": "…", "clientId": "…", "scopes": ["profile", "billing:read"] } }
```

### Tools run as that user

The gate attaches the resolved session to `event.context.backendMcp`; [`useBackendMcp()`](https://nuxt-backend.dev/docs/agents/mcp/custom-tools) hands tool handlers Convex fetchers bound to the JWT. Every claims-first consumer — authorization, workspace resolution, billing-entity resolution — reads the call exactly like a web session. The token belongs to no session, though: `authComponent.getAuthUser` and `safeGetAuthUser` read the agent's account by its `sub` claim instead (a banned or deleted account reads as signed out), `getHeaders` and `getAuth` carry no session token, `listSessions` returns `null` and `revokeSession` refuses.

</steps>

## The exchange cache

One round trip per token per JWT lifetime. The gate keeps resolved sessions in an in-memory TTL + LRU cache keyed by the opaque Bearer:

<table>
<thead>
  <tr>
    <th>
      Property
    </th>
    
    <th>
      Value
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Entry TTL
    </td>
    
    <td>
      The minted JWT's lifetime <strong>
        minus a 30 s margin
      </strong>
      
      , so a cached JWT is never handed out moments before it lapses
    </td>
  </tr>
  
  <tr>
    <td>
      Capacity
    </td>
    
    <td>
      500 entries, least-recently-used evicted first
    </td>
  </tr>
  
  <tr>
    <td>
      Scope
    </td>
    
    <td>
      Per Nitro instance, in memory — nothing is persisted or shared
    </td>
  </tr>
</tbody>
</table>

That TTL is also the **revocation bound**: because the JWT lives five minutes and the cache expires with it, revoking an agent's OAuth token cuts its Convex access within minutes, with no distributed invalidation to build or operate.

Failures are deliberately separated, so a client is never told to re-authenticate over an infrastructure problem:

<table>
<thead>
  <tr>
    <th>
      From the exchange
    </th>
    
    <th>
      The gate answers
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        401
      </code>
      
       (unknown / expired / revoked token, banned or deleted account)
    </td>
    
    <td>
      <code>
        401
      </code>
      
       with the discovery challenge
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        429
      </code>
      
       (the <code>
        mcp
      </code>
      
       limit)
    </td>
    
    <td>
      <code>
        429 Too many agent requests — try again shortly.
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      No Convex site URL configured
    </td>
    
    <td>
      <code>
        503 Agent endpoint unavailable
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Any other non-2xx (route not mounted, deployment unreachable)
    </td>
    
    <td>
      <code>
        502 Agent token exchange failed (<status>)
      </code>
    </td>
  </tr>
</tbody>
</table>

## Scope semantics

The provider offers a fixed catalogue plus anything you add. Reads never need more than a `*:read` scope, and no scope in the set authorizes an irreversible action:

<table>
<thead>
  <tr>
    <th>
      Scope
    </th>
    
    <th>
      Grants
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        openid
      </code>
      
       · <code>
        profile
      </code>
      
       · <code>
        email
      </code>
    </td>
    
    <td>
      Identity — the OIDC scopes the provider always grants
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        profile:write
      </code>
    </td>
    
    <td>
      The display <strong>
        name
      </strong>
      
       only. Email changes stay in the verified web flow, so an agent can never move an account to an unproven address
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        billing:read
      </code>
    </td>
    
    <td>
      Plans, the current subscription, credit balances — reads
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        billing:checkout
      </code>
    </td>
    
    <td>
      Checkout and portal <strong>
        links only
      </strong>
      
      . The human opens them in a browser; no tool executes a payment, and success/return paths must be app-relative
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        workspace:read
      </code>
    </td>
    
    <td>
      The workspace list and member lists
    </td>
  </tr>
</tbody>
</table>

Enforcement is two-layered: a tool whose scope is missing is hidden from `tools/list`, **and** the handler re-checks and refuses with `403`. Add your own scopes with `setupAuth({ mcp: { scopes: [...] } })` — see [custom tools](https://nuxt-backend.dev/docs/agents/mcp/custom-tools#custom-scopes).

## Rate limiting the exchange

`DEFAULT_LIMITS.mcp` is a token bucket of **60 exchanges per minute per client + user, burst 20**. It guards the endpoint agents call on the app's behalf; pass the limiter into `setupAuth` for it to apply:

```ts [backend/auth.ts]
export const { authComponent, createAuth, mcp /* … */ } = setupAuth(components, query, {
  email,
  rateLimiter,   // from backend/rateLimiter.ts
})
```

Override it like any other named limit — see [Rate limiting](https://nuxt-backend.dev/docs/ship/production/rate-limits).

## Consent

Every authorization stops at the consent page, `/oauth/consent` (module option `pages.agentConsent`), whatever the agent asked for: `setupAuth` sets `prompt=consent` on every request to `/mcp/authorize`, so no agent gets a code without the person approving it. The page shows:

- the name the agent registered under, marked **unverified**: dynamic registration lets an agent pick any name;
- the host approving sends the browser back to;
- every scope the agent asked for, read from the stored request rather than from the page's URL, which the agent wrote.

Approve and Deny both return the browser to the agent: with a code, or with `access_denied`. Around that:

- **PKCE is required** (`S256`). An authorization without a code challenge is refused, and the token endpoint checks the verifier.
- **A code waiting for consent is worthless.** `/mcp/token` refuses it, so a consent code that leaked cannot be traded for a token. The page also takes the code out of the address bar, sends `Referrer-Policy: no-referrer`, and cannot be framed (`X-Frame-Options: DENY`, `frame-ancestors 'none'`).
- **Only the person can answer.** A request answers to the account it was made for, and an administrator impersonating that account cannot approve it; denying stays possible. Both hold for the session the consent route runs with, whether its token came in a cookie or as `Authorization: Bearer`.
- **Registration has a ceiling.** `/mcp/register` is open to anyone by design, so `agentRegistrationGlobal` caps it at 100 registrations an hour for the whole deployment.

No option skips consent, and no client is trusted in advance. `setupAuth`'s `mcp` options:

```ts [backend/auth.ts]
setupAuth(components, query, {
  email,
  mcp: {
    scopes: ['todos:write'],          // extra scopes on top of the built-in catalogue
    loginPath: '/login',              // where a person signs in mid-authorization (default)
    consentPath: '/oauth/consent',    // where a person approves or denies (default)
  },
})
```

<note>

`loginPath` and `consentPath` each name a page the Nuxt side mounts: keep them equal to `backend.pages.login` and `backend.pages.agentConsent`. `nuxt-backend doctor` compares both pairs.

</note>

For a screen of your own, put an app page at `/oauth/consent` (it shadows the module's) and render `<AgentConsent>` with its slots, or build on `useAgentConsent()`. See [Composables](https://nuxt-backend.dev/docs/reference/nuxt/composables).

## What production needs

- **SITE_URL** on the deployment set to the public app origin. It is the only trusted browser origin for auth, and the OAuth redirects, discovery documents and checkout links are all built on that origin.
- **A backend site URL on the Nuxt side** (`NUXT_PUBLIC_BACKEND_SITE_URL`, `backend.siteUrl`, or derived from `CONVEX_DEPLOYMENT`) — without it the gate answers `503`.
- **The exchange route mounted**: `registerBackendRoutes(http, { auth: { authComponent, createAuth, mcp }, … })`. Missing → `502` on every agent call.
- **The rate limiter passed** to `setupAuth` as `rateLimiter`, so the `mcp` limit actually applies.
- **A decision on the scopes** your users grant: each built-in tool needs one, listed in [Built-in tools](https://nuxt-backend.dev/docs/agents/mcp/built-in-tools).
- **HTTPS**, and a publicly reachable origin: a remote client cannot reach `localhost`. See [Connect a client](https://nuxt-backend.dev/docs/agents/mcp/connect-a-client).
- Remember that rotating **AUTH_SECRET** invalidates sessions *and* the stored signing keys — agents must re-run the OAuth flow. Treat it as incident response, not routine ([Troubleshooting](https://nuxt-backend.dev/docs/ship/operate/troubleshooting)).

The [production checklist](https://nuxt-backend.dev/docs/ship/production) carries these.


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
