> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentfront.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Local OAuth Provider

> Configure FrontMCP's built-in OAuth 2.1 authorization server

FrontMCP includes a built-in OAuth 2.1 authorization server for self-contained authentication scenarios.

<Warning>
  The built-in login page accepts any email format without validation and is intended for development. Replace with a real identity provider for production use.
</Warning>

## Token Signing (HS256)

Local mode signs every token it issues — access tokens and the anonymous JWT — with **HS256, a symmetric secret**. The secret comes from the `JWT_SECRET` environment variable:

```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
JWT_SECRET=$(openssl rand -hex 32)
```

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@FrontMcp({
  info: { name: 'MyServer', version: '1.0.0' },
  auth: { mode: 'local' },
})
export class Server {}
```

<Warning>
  If `JWT_SECRET` is unset, FrontMCP logs a warning and falls back to a **random per-process secret**. Every restart then invalidates all previously issued tokens. Set a stable `JWT_SECRET` for any deployment where tokens must survive a restart.
</Warning>

Because the **access tokens** are signed symmetrically, the same `JWT_SECRET` both signs and verifies them. Separately, local mode **does** auto-generate an asymmetric (RS256 by default) key pair and publishes its public half at `/.well-known/jwks.json` for OAuth-discovery compatibility. That published key is **not** used to verify the HS256 access tokens — those are always verified with `JWT_SECRET`. The asymmetric key exists only so discovery-driven clients find a non-empty JWKS document.

## Basic Configuration

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@FrontMcp({
  info: { name: 'MyServer', version: '1.0.0' },
  auth: {
    mode: 'local',
  },
})
export class Server {}
```

## Configuration Options

| Option               | Type                                                                                    | Default                 | Description                                                                                                                                                                           |
| -------------------- | --------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tokenStorage`       | `'memory' \| { redis: RedisConfig } \| { sqlite: SqliteConfig }`                        | `'memory'`              | Token storage backend (see [Token Storage](#token-storage))                                                                                                                           |
| `secureStore`        | `'memory' \| { sqlite } \| { redis } \| { backend } \| { scope?, ttlMs?, encryption? }` | `'memory'` (user scope) | Backing for the general session secure-secret store `this.secureStore` (see [Secure-Secret Store](#session-secure-secret-store-thissecurestore))                                      |
| `requireEmail`       | `boolean`                                                                               | `true`                  | Require an email at the login callback (see [Single-Operator](#single-operator-mode-requireemail-false))                                                                              |
| `anonymousSubject`   | `string`                                                                                | `'local-operator'`      | Stable `sub` minted when `requireEmail: false` and no email is provided                                                                                                               |
| `login`              | `LoginConfig`                                                                           | *unset*                 | Customize the built-in login page (title, fields, full HTML override, subject strategy) — see [Custom Login & Verification](#custom-login-fields-and-verification-login-authenticate) |
| `authenticate`       | `(input, ctx) => Promise<AuthenticateResult>`                                           | *unset*                 | Custom verification step run at the login callback before a token is minted — see [Custom Login & Verification](#custom-login-fields-and-verification-login-authenticate)             |
| `consent`            | `ConsentConfig`                                                                         | *unset*                 | Consent / tool-authorization config — the block is optional; when present, inner `enabled` defaults to `false` (see [Consent](#consent-and-tool-authorization))                       |
| `providers`          | `UpstreamProviderOptions[]`                                                             | *unset*                 | Upstream OAuth providers to orchestrate (see [Multi-Provider Orchestration](#multi-provider-orchestration-providers))                                                                 |
| `federatedAuth`      | `FederatedAuthConfig`                                                                   | *unset*                 | Federated-login gating: `minProviders`, `requiredProviders`, `stateValidation`                                                                                                        |
| `allowDefaultPublic` | `boolean`                                                                               | `false`                 | Allow unauthenticated access                                                                                                                                                          |
| `anonymousScopes`    | `string[]`                                                                              | `['anonymous']`         | Scopes for anonymous sessions                                                                                                                                                         |
| `local.issuer`       | `string`                                                                                | auto-derived            | Override the issuer (see [Behind a Tunnel](#behind-a-tunnel-or-reverse-proxy))                                                                                                        |
| `incrementalAuth`    | `IncrementalAuthConfig`                                                                 | *unset*                 | Progressive authorization config — the block is optional; when present, inner `enabled` defaults to `true`                                                                            |

***

## OAuth Endpoints

Local mode exposes standard OAuth 2.1 endpoints:

| Endpoint                                  | Method   | Description                                                                                                         |
| ----------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
| `/oauth/authorize`                        | GET      | Start authorization flow                                                                                            |
| `/oauth/token`                            | POST     | Exchange code for tokens                                                                                            |
| `/oauth/register`                         | POST     | Dynamic Client Registration                                                                                         |
| `/oauth/userinfo`                         | GET      | Get user profile (verifies the Bearer token; 401 if missing/invalid)                                                |
| `/oauth/connect`                          | GET/POST | Mid-session credential-connect flow for `this.credentials` (connect additional credentials to the current identity) |
| `/.well-known/oauth-authorization-server` | GET      | Authorization-server metadata (AS)                                                                                  |
| `/.well-known/oauth-protected-resource`   | GET      | Protected-resource metadata (PRM)                                                                                   |
| `/.well-known/jwks.json`                  | GET      | Public signing keys (discovery key; not used to verify HS256 access tokens)                                         |

### Authorization Request

```
GET /oauth/authorize
  ?response_type=code
  &client_id=your-client-id
  &redirect_uri=http://localhost:3000/callback
  &scope=openid profile
  &state=random-state
  &code_challenge=base64url(sha256(verifier))
  &code_challenge_method=S256
```

### Token Exchange

```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=authorization-code
&redirect_uri=http://localhost:3000/callback
&client_id=your-client-id
&code_verifier=original-verifier
```

### OAuth Callback Flow (`/oauth/callback`)

The callback endpoint processes the user's response from the authorization page and creates an authorization code. It executes through the following stages:

| Stage                     | Description                                               |
| ------------------------- | --------------------------------------------------------- |
| `parseInput`              | Extract query parameters (email, pending\_auth\_id, etc.) |
| `validatePendingAuth`     | Verify pending auth record and validate providers         |
| `handleIncrementalAuth`   | Process progressive authorization for apps                |
| `handleFederatedAuth`     | Chain through federated identity providers                |
| `createAuthorizationCode` | Generate authorization code with consent data             |
| `redirectToClient`        | Redirect user back to client with code                    |

#### Callback Query Parameters

| Parameter         | Description                                         |
| ----------------- | --------------------------------------------------- |
| `pending_auth_id` | Reference to pending authorization record           |
| `email`           | User's email address                                |
| `name`            | User's display name (optional)                      |
| `incremental`     | Set to `true` for progressive auth (optional)       |
| `app_id`          | Target app for incremental authorization (optional) |
| `federated`       | Set to `true` for federated login (optional)        |
| `providers`       | Selected provider IDs (optional, array or string)   |
| `tools`           | Selected tool IDs for consent (optional, array)     |

#### Federated Provider Chaining

When `federated=true` and providers are selected:

1. A federated session is created to track progress
2. PKCE is generated for the first provider
3. User is redirected to the first provider's authorization URL
4. After each provider completes, the chain continues to the next
5. Once all providers complete, an authorization code is created

<Warning>
  Selected provider IDs are validated against the pending authorization's allowed providers. Invalid provider IDs are rejected with a 400 error.
</Warning>

#### Error Handling

* Missing `pending_auth_id`: Returns 400 with error page
* Expired authorization: Returns 400 with "request expired" message
* Invalid provider selection: Returns 400 with "invalid provider selection" message
* Missing required fields: Returns 500 with error page

***

## Multi-Provider Orchestration (`providers`)

Declare upstream OAuth providers (GitHub, Slack, Jira, …) directly on `mode: 'local'` to make multi-provider orchestration a turnkey default. FrontMCP then:

* shows a federated provider-selection page at `/oauth/authorize`,
* exchanges each provider's code at `/oauth/provider/{id}/callback`,
* stores each provider's tokens **encrypted, server-side** (never exposed to the LLM),
* exposes them to tools via `this.orchestration.getToken(id)`.

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@FrontMcp({
  auth: {
    mode: 'local',
    // Orchestrate GitHub + Slack + Jira as the local default.
    providers: [
      {
        id: 'github',
        // `authorizeUrl`/`tokenUrl` are accepted aliases for
        // `authorizationEndpoint`/`tokenEndpoint`.
        authorizeUrl: 'https://github.com/login/oauth/authorize',
        tokenUrl: 'https://github.com/login/oauth/access_token',
        clientId: process.env.GITHUB_CLIENT_ID!,
        clientSecret: process.env.GITHUB_CLIENT_SECRET,
        scopes: ['read:user', 'repo'],
      },
      { id: 'slack', authorizeUrl: '…', tokenUrl: '…', clientId: '…', scopes: ['users:read'] },
      { id: 'jira', authorizeUrl: '…', tokenUrl: '…', clientId: '…' },
    ],
    // No JWT is minted until at least one provider is linked.
    federatedAuth: { minProviders: 1 },
  },
})
export class Server {}
```

### Provider fields (`UpstreamProviderOptions`)

| Field                   | Type       | Required | Description                                                  |
| ----------------------- | ---------- | -------- | ------------------------------------------------------------ |
| `id`                    | `string`   | yes      | Stable provider id used in `this.orchestration.getToken(id)` |
| `authorizationEndpoint` | `string`   | yes\*    | Authorization endpoint (alias: `authorizeUrl`)               |
| `tokenEndpoint`         | `string`   | yes\*    | Token endpoint (alias: `tokenUrl`)                           |
| `clientId`              | `string`   | yes      | OAuth client id from the upstream provider                   |
| `clientSecret`          | `string`   | no       | OAuth client secret (confidential clients)                   |
| `scopes`                | `string[]` | no       | Scopes to request (defaults to `[]`)                         |
| `name`                  | `string`   | no       | Display name (defaults to `id`)                              |
| `userInfoEndpoint`      | `string`   | no       | User-info endpoint to enrich the session identity            |
| `jwksUri`               | `string`   | no       | JWKS URI for upstream `id_token` validation                  |

\* At least one of the canonical name or its alias is required for each endpoint. The per-provider callback URL is computed automatically as `${issuer}/oauth/provider/${id}/callback` — register that exact URL with each provider.

### Gating the JWT (`federatedAuth`)

`federatedAuth` controls when a FrontMCP JWT is minted during federated login:

| Field               | Type                   | Default              | Description                                                        |
| ------------------- | ---------------------- | -------------------- | ------------------------------------------------------------------ |
| `minProviders`      | `number`               | `1` (when providers) | Refuse to mint a JWT until at least this many providers are linked |
| `requiredProviders` | `string[]`             | *unset*              | Every id listed here must be among the linked providers            |
| `stateValidation`   | `'strict' \| 'format'` | `'strict'`           | How strictly to validate the OAuth `state` on provider callbacks   |

When `providers` are configured the default minimum is **1** ("no JWT until ≥1 linked"); the callback returns a `400` until the threshold (and every `requiredProviders` id) is satisfied.

### Reading upstream tokens in tools (`this.orchestration`)

In orchestrated (`local`/`remote`) mode, authenticated tool calls can read the linked providers' tokens via `this.orchestration`:

```ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@Tool({ name: 'github_repos' })
class GitHubReposTool extends ToolContext {
  async execute() {
    // Throws if the provider was not linked.
    const token = await this.orchestration.getToken('github');
    // Or null-returning variant:
    const maybe = await this.orchestration.tryGetToken('slack');

    if (this.orchestration.hasProvider('jira')) {
      const jira = await this.orchestration.getToken('jira');
    }

    const res = await fetch('https://api.github.com/user/repos', {
      headers: { Authorization: `Bearer ${token}` },
    });
    return { repos: await res.json() };
  }
}
```

`getToken(id)` resolves the decrypted upstream token from server-side storage (refreshing it when a refresh token is available); tokens are never placed in the JWT or exposed to the model. `tryGetToken(id)` returns `null` instead of throwing when a provider was skipped.

<Note>
  No PII is stored by FrontMCP: only the upstream provider tokens (AES-256-GCM encrypted at rest) and the non-PII provider ids are persisted.
</Note>

***

## Behind a Tunnel or Reverse Proxy

OAuth discovery (`/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server`) is **derived from the incoming request host** at runtime, and the OAuth endpoints are advertised at the root (`/oauth/authorize`, `/oauth/token`, …). This means discovery works correctly behind a tunnel (e.g. `ngrok`, Cloudflare Tunnel) or when the server is mounted under an `http.entryPath` — no extra configuration needed for discovery itself.

Two knobs affect the **boot-time issuer** (the `iss` claim baked into tokens before any request arrives):

* **`FRONTMCP_PUBLIC_HOST`** — sets only the discovery **host** for the boot-time issuer. The scheme stays `http` and the port stays the configured HTTP port.
* **`local.issuer`** — sets the full issuer string. Use this when you need a different scheme/port (e.g. advertise `https://mcp.example.com` with no explicit port behind a TLS-terminating proxy).

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
auth: {
  mode: 'local',
  local: {
    // Aligns the token `iss` with what clients reach through the proxy.
    issuer: 'https://mcp.example.com',
  },
}
```

<Info>
  Token verification accepts the issuer, so tokens minted under a different scheme/port are still tolerated behind a TLS proxy. `local.issuer` is the supported way to make the boot-time issuer match what discovery advertises.
</Info>

***

## Single-Operator Mode (`requireEmail: false`)

The built-in login callback normally requires an email. For single-operator local setups — for example pointing Claude Code at a `mode: 'local'` server — set `requireEmail: false` so a login mints an authorization code without prompting for an email:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
auth: {
  mode: 'local',
  requireEmail: false,            // skip the email prompt at /oauth/callback
  anonymousSubject: 'local-operator', // stable `sub` for the single operator (default)
}
```

When `requireEmail` is `false` and no email is supplied, the callback derives a **stable** `sub` from `anonymousSubject` (default `'local-operator'`), so the operator keeps a consistent identity across logins. With the default `requireEmail: true`, a non-incremental login without an email is rejected with a 400.

***

## Custom Login Fields and Verification (`login` / `authenticate`)

Local mode ships a zero-config email/name login page. To collect different
credentials and verify them yourself — for example an API key checked against an
upstream service — add a declarative `login` config and an `authenticate`
verifier. **Both are optional; omitting them preserves the default login page and
flow exactly.**

<Tip>
  For a fully custom page — your own React component for the login, consent, federated, incremental, or error slot, with the framework still owning CSRF + CSP — use the [`auth.ui` slot→file map](/frontmcp/authentication/custom-ui) instead of (or alongside) the declarative `login` fields.
</Tip>

### Declarative login fields

`login.fields` appends (or, in the built-in page, replaces the default email/name
inputs with) custom fields. Each field is keyed by its form name:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
auth: {
  mode: 'local',
  login: {
    title: 'Sign in with your API key',
    subtitle: 'Paste your key to continue',
    logoUri: 'https://acme.example/logo.png',
    fields: {
      apiKey: { type: 'password', label: 'API Key', required: true, placeholder: 'sk-...' },
      region: {
        type: 'select',
        label: 'Region',
        options: [
          { value: 'us', label: 'United States' },
          { value: 'eu', label: 'Europe' },
        ],
      },
    },
    // Derive a stable subject from a submitted field (same value → same `sub`).
    subject: { fromField: 'apiKey', strategy: 'per-account' },
  },
}
```

Field `type` is one of `'text' | 'password' | 'email' | 'select' | 'hidden'`.
For a `select`, supply `options: Array<{ value; label }>`.

For full control over the markup, provide `login.render` instead — it receives a
`LoginRenderContext` (`clientId`, `clientName`, `logoUri`, `scopes`,
`pendingAuthId`, `callbackPath`, `fields`, and `error` on re-render) and must
return a complete HTML document. The form must submit `pending_auth_id` and the
field values back to `callbackPath`.

### The `authenticate` verifier

`authenticate` runs at the login callback before any token is minted. It receives
the submitted fields and a context, and returns a discriminated result:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
auth: {
  mode: 'local',
  login: { fields: { apiKey: { type: 'password', label: 'API Key', required: true } } },
  authenticate: async (input, ctx) => {
    // Verify the submitted credential (e.g. against an upstream API).
    const res = await ctx.fetch('https://api.example.com/verify', {
      method: 'POST',
      headers: { authorization: `Bearer ${input.fields.apiKey}` },
    });
    if (!res.ok) {
      return { ok: false, message: 'Invalid API key', retryField: 'apiKey' };
    }
    const { accountId, tier } = (await res.json()) as { accountId: string; tier: string };
    return {
      ok: true,
      sub: accountId,                 // explicit subject (optional)
      claims: { tenantId: accountId, plan: tier }, // custom JWT claims
    };
  },
}
```

* **`input.fields`** — the submitted login fields (built-in `email`/`name` plus
  any `login.fields`). Reserved OAuth/flow-control params (`pending_auth_id`,
  `scope`, `client_id`, `redirect_uri`, `code`, …) are never included.
* **`ctx`** — `{ get<T>(token), fetch(input, init?), logger, clientId?, clientName? }`.
  Use `ctx.get` to resolve DI services and `ctx.fetch` for outbound verification.
* **On `{ ok: true }`** — a token is minted. The subject is `result.sub`, else the
  `login.subject` strategy (`per-account` hashes `fromField`), else the
  `anonymousSubject` fallback. `result.claims` are embedded in the access token.
* **On `{ ok: false }`** — the login page is re-rendered with `message` shown and
  submitted values preserved; **no code is issued**. Use `retryField` to hint
  which field to correct.

When `authenticate` is set, the built-in **email requirement no longer applies** —
your verifier owns required-field semantics via `login.fields`.

### Custom claims in the token

`result.claims` are merged into the signed access token alongside the standard
claims. **Reserved claims (`sub`, `iss`, `aud`, `exp`, `iat`, `nbf`, `jti`,
`scope`, `email`, `name`, `picture`, `roles`, `consent`, `federated`) are
stripped** so a verifier can never forge identity, lifetime, or scope. Read them
back from the auth context (e.g. via an authorities claims pipe) or directly off
the verified JWT.

***

## Per-Session Credential Vault (`this.credentials`)

An `{ ok: true }` result may return `credentials: Array<{ key; secret; metadata? }>`.
FrontMCP **persists** these into a built-in, per-session, **AES-256-GCM-encrypted**
credential vault keyed by the authenticated subject (`sub`), and exposes them to
tools via `this.credentials`. The vault is enabled automatically in `local` (and
`remote`) modes — there is nothing to wire up.

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
auth: {
  mode: 'local',
  login: {
    fields: { apiKey: { type: 'password', label: 'API Key', required: true } },
  },
  authenticate: async (input) => {
    const apiKey = input.fields['apiKey'];
    if (!(await isValid(apiKey))) return { ok: false, message: 'Invalid API key' };
    return {
      ok: true,
      // Persisted, encrypted, keyed by the minted `sub`:
      credentials: [{ key: 'acme', secret: apiKey, metadata: { baseUrl: 'https://acme.example' } }],
    };
  },
}
```

Read credentials from any tool via `this.credentials`:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@Tool({
  name: 'call_acme',
  inputSchema: {},
  outputSchema: { connected: z.boolean(), connectUrl: z.string().optional() },
})
class CallAcmeTool extends ToolContext {
  async execute() {
    const cred = await this.credentials.get('acme'); // { secret, metadata } | undefined
    if (!cred) {
      // Ask the agent to connect it mid-session via a framework-signed URL:
      const res = await this.credentials.requireConnect({ key: 'acme' });
      if (!res.connected) return { connected: false, connectUrl: res.resumeUrl };
    }
    // use cred!.secret / cred!.metadata … (e.g. call Acme with the secret)
    return { connected: true };
  }
}
```

### Vault behavior and security

* **At-rest encryption** — credentials are sealed with AES-256-GCM via the shared
  `VaultEncryption` primitive. The per-record key is derived (HKDF) from a fresh
  per-session `vaultId` plus a pepper from `VAULT_SECRET ?? JWT_SECRET`. When
  neither is set, a random per-process pepper is used (logged with a warning) and
  credentials do not survive a restart — never plaintext.
* **Per-session rotation** — every fresh authorize mints a new `vaultId`, so a
  **disconnect + reconnect yields an empty vault** and the prior session's
  ciphertext becomes undecryptable.
* **Subject isolation** — the derived key mixes in the `sub`, so one subject can
  never read another's credentials.
* **Backed by `tokenStorage`** — the vault uses the same `auth.tokenStorage`
  backend (memory / Redis / SQLite), so it is persistent wherever your token
  stores are.
* **No PII** — only the credential `secret`/`metadata` you pass are stored
  (encrypted); FrontMCP never persists login PII.

### `this.credentials` API

| Method                              | Returns                                      | Description                                                             |
| ----------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------- |
| `get(key)`                          | `Promise<{ secret, metadata } \| undefined>` | Decrypt a credential for the request's subject.                         |
| `list()`                            | `Promise<string[]>`                          | List credential keys in the current session vault.                      |
| `requireConnect({ key, context? })` | `Promise<RequireConnectResult>`              | Return the credential if connected, else a framework-signed resume URL. |

### Mid-session connect (`requireConnect` + `/oauth/connect`)

When a tool needs a credential that is not connected, `requireConnect({ key })`
returns a **framework-signed**, short-lived resume URL
(`/oauth/connect?token=…`). The token is an HMAC over `{ sub, key, context, exp }`
signed with the server secret and verified constant-time, so it cannot be
retargeted to another subject/key. Opening the URL renders a single-field
add-credential page (reusing your `login.fields`); on submit FrontMCP re-invokes
your `authenticate()` with a `resume` context and **additively** stores the
returned credential into the existing session vault:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
authenticate: async (input) => {
  if (input.resume) {
    // Mid-session connect: { sub, key, context? } is set.
    const value = input.fields[Object.keys(input.fields)[0]];
    if (!(await isValid(value))) return { ok: false, message: 'Invalid value' };
    return { ok: true, credentials: [{ key: input.resume.key, secret: value }] };
  }
  // …initial login branch…
}
```

If the subject's vault has been rotated away (the session is gone), the connect
is **refused** — a stale link can never resurrect a dead session.

***

## Session Secure-Secret Store (`this.secureStore`)

`this.credentials` is purpose-built for **OAuth credentials** (resume URLs,
per-authorize vault rotation). For **arbitrary user-typed secrets** — an API key
a tool prompts for, a webhook signing secret, a per-session draft token — use
`this.secureStore`: a general, typed, session-scoped key→secret store whose
backing is **pluggable per deployment**. You stop re-implementing AES-GCM, HKDF
key derivation, scoping, and persistence yourself.

Configure the backing with `auth.secureStore`:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@FrontMcp({
  apps: [MyApp],
  auth: {
    mode: 'local',
    // 'memory' | { sqlite } | { redis } | { backend } | { scope?, ttlMs?, encryption? }
    secureStore: {
      sqlite: { path: './.frontmcp/secrets.sqlite' }, // survives restart
      scope: 'user', // 'user' (default) | 'session' | 'global'
    },
  },
})
export default class Server {}
```

Then read/write from any tool (or the auth UI) via `this.secureStore`:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@Tool({ name: 'save_api_key' })
class SaveApiKeyTool extends ToolContext {
  async execute(input: { apiKey: string }) {
    await this.secureStore.set('stg.api-key', input.apiKey); // JSON-serialized
    const key = await this.secureStore.get<string>('stg.api-key');
    const all = await this.secureStore.list();
    // await this.secureStore.delete('stg.api-key');
    return { saved: key !== undefined, keys: all };
  }
}
```

### Backings

| Backing                | Use case                                | Persistence      | At-rest encryption                                  |
| ---------------------- | --------------------------------------- | ---------------- | --------------------------------------------------- |
| `'memory'` (default)   | Local dev, ephemeral CI, no Redis       | Process lifetime | AES-256-GCM, HKDF(scope + server pepper)            |
| `{ sqlite: { path } }` | Single-host persistence, no Redis       | Survives restart | Same `VaultEncryption` over the SQLite-file adapter |
| `{ redis: {...} }`     | Multi-instance / serverless persistence | Survives restart | Same `VaultEncryption` over the Redis adapter       |
| `{ backend }`          | OS keychain or any custom store         | Owned by backend | Owned by backend (OS keychain is OS-encrypted)      |

The object forms also accept `scope`, `ttlMs` (default TTL for writes), and
`encryption.pepper` (overrides the server `VAULT_SECRET ?? JWT_SECRET`).

### Scope

`scope` controls how the namespace is derived from the request (the identity is
hashed into the namespace — never stored raw):

* **`user`** (default): keyed by the authenticated `sub`. Survives a reconnect
  for the same user. Anonymous requests get no namespace (reads return empty,
  writes are skipped).
* **`session`**: keyed by the transport `sessionId`. Disappears when the session
  ends.
* **`global`**: a single server-wide namespace. Use for operator-level secrets
  not tied to any one user/session.

### `this.secureStore` API

| Method                      | Returns                   | Description                                                        |
| --------------------------- | ------------------------- | ------------------------------------------------------------------ |
| `get<T>(key)`               | `Promise<T \| undefined>` | Read + JSON-parse a secret for the current scope.                  |
| `set<T>(key, value, opts?)` | `Promise<void>`           | JSON-serialize + store a secret. `opts.ttlMs` bounds its lifetime. |
| `delete(key)`               | `Promise<boolean>`        | Remove a secret; `true` when one existed.                          |
| `list()`                    | `Promise<string[]>`       | List secret keys in the current scope.                             |

### OS keychain (pluggable, not bundled)

FrontMCP deliberately **does not bundle a native keychain dependency**
(`keytar`/`wincred`/`libsecret`) into the framework. Instead the backing is
**pluggable**: supply an object implementing the four-method `SecureStoreBackend`
contract and the framework uses it as-is (no framework crypto is applied — an OS
keychain is encrypted by the OS).

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import type { SecureStoreBackend } from '@frontmcp/sdk';

// Thin wrapper around your platform's keychain (peer-dep loaded by YOU).
const keychainBackend: SecureStoreBackend = {
  async get(namespace, key) {
    return (await keychain.getPassword(`frontmcp:${namespace}`, key)) ?? null;
  },
  async set(namespace, key, value /*, ttlMs */) {
    await keychain.setPassword(`frontmcp:${namespace}`, key, value); // ttlMs ignored
  },
  async delete(namespace, key) {
    return keychain.deletePassword(`frontmcp:${namespace}`, key);
  },
  async list(/* namespace */) {
    return keychain.listKeys(); // map to your keychain's enumeration API
  },
};

@FrontMcp({
  apps: [MyApp],
  auth: { mode: 'local', secureStore: { backend: keychainBackend, scope: 'global' } },
})
export default class Server {}
```

A backend deals only in `(namespace, key) → string`; the accessor resolves the
namespace from the scope and handles JSON serialization. Backends that cannot
honor a TTL (an OS keychain) simply ignore the `ttlMs` argument.

<Note>
  `this.secureStore` and `this.credentials` are independent and can be used
  together. `this.credentials` remains the right tool for OAuth credentials with
  mid-session connect; `this.secureStore` is for general user-typed secrets.
</Note>

***

## Dynamic Client Registration (`dcr`)

DCR lets clients register programmatically at `POST /oauth/register` (RFC 7591). By
default the endpoint is **enabled in development and disabled in production**
(`NODE_ENV=production` short-circuits it as if it did not exist), registered clients
are held in an in-memory map, and there is no allowlist.

The optional `dcr` block on `auth` (local mode) is a declarative control surface for
that endpoint and for the client allowlist enforced at `/oauth/authorize`. **Omitting
`dcr` preserves the default behavior exactly** — it only changes behavior for the
fields you set.

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@FrontMcp({
  info: { name: 'MyServer', version: '1.0.0' },
  auth: {
    mode: 'local',
    dcr: {
      // Turn DCR off explicitly (regardless of NODE_ENV). The endpoint then
      // responds 404 and `registration_endpoint` is dropped from AS metadata.
      enabled: false,
      // Only these redirect URIs are accepted — at /oauth/register AND
      // /oauth/authorize. Entries are exact or a simple `*` glob.
      allowedRedirectUris: ['https://app.example.com/callback', 'http://localhost:*/callback'],
      // Only these client_ids may be used at /oauth/authorize.
      allowedClientIds: ['dashboard'],
      // Require `Authorization: Bearer <token>` on /oauth/register (RFC 7591 §3).
      initialAccessToken: process.env.DCR_INITIAL_ACCESS_TOKEN,
      // Trusted clients seeded at startup — accepted WITHOUT a DCR round-trip,
      // so you can disable DCR and still ship known clients.
      clients: [
        {
          clientId: 'dashboard',
          redirectUris: ['https://app.example.com/callback'],
          clientName: 'Internal Dashboard',
        },
      ],
    },
  },
})
export class Server {}
```

| `dcr` field           | Type               | Default            | Effect                                                                                                                                                                                                                                                                                                                                                                                |
| --------------------- | ------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`             | `boolean`          | dev: on, prod: off | When `false`, `/oauth/register` returns `404` and `registration_endpoint` is omitted from AS metadata.                                                                                                                                                                                                                                                                                |
| `allowedRedirectUris` | `string[]`         | *(none)*           | Exact or `*`-glob allowlist. A `redirect_uri` not on it is rejected at **register** (`400 invalid_redirect_uri`) and **authorize** (error page — never redirected).                                                                                                                                                                                                                   |
| `allowedClientIds`    | `string[]`         | *(none)*           | Only these `client_id`s may be used at `/oauth/authorize`. CIMD URL client ids are validated by CIMD instead and are exempt.                                                                                                                                                                                                                                                          |
| `initialAccessToken`  | `string`           | *(none)*           | When set, `/oauth/register` requires a matching `Authorization: Bearer <token>` (constant-time compared) → `401` otherwise.                                                                                                                                                                                                                                                           |
| `clients`             | `LocalDcrClient[]` | *(none)*           | Pre-registered trusted clients seeded at startup; accepted by authorize/token **without** a DCR round-trip.                                                                                                                                                                                                                                                                           |
| `maxDynamicClients`   | `number`           | `1000`             | Cap on **dynamically-registered** clients kept in memory. Once reached, further DCR registrations are **rejected** (`503 temporarily_unavailable`) — existing clients (including confidential ones) are preserved, never evicted. `0` disables dynamic registration. Pre-registered `clients` don't count toward it. Guards against unbounded memory growth from unauthenticated DCR. |

<Note>
  This `dcr` block governs the **local Authorization Server**. It is unrelated to the
  upstream-provider `providerConfig.dcrEnabled` / `registrationEndpoint` fields, which
  control registering THIS server as a client against an upstream IdP.
</Note>

### Registration Request

```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
POST /oauth/register
Content-Type: application/json
# Authorization: Bearer <token>   # required only when dcr.initialAccessToken is set

{
  "redirect_uris": ["http://localhost:3000/callback"],
  "client_name": "My Application",
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"]
}
```

### Registration Response

A successful registration responds `201 Created`:

```json theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
{
  "client_id": "generated-uuid",
  "client_id_issued_at": 1234567890,
  "client_secret_expires_at": 0,
  "redirect_uris": ["http://localhost:3000/callback"],
  "client_name": "My Application",
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"]
}
```

<Warning>
  Without a `dcr` block, DCR is disabled in production (`NODE_ENV=production`) and in
  development accepts `redirect_uris` whose host is `localhost` or any IPv4 address (any
  dotted-quad, not just loopback), over `http` **or** `https`, with only the `none`,
  `client_secret_post`, and `client_secret_basic` auth methods. For production, set
  `dcr` explicitly: disable DCR (or require an `initialAccessToken`), constrain
  `allowedRedirectUris` / `allowedClientIds`, and seed trusted `clients`.
</Warning>

***

## Per-App Configuration

Configure local auth per app with `splitByApp: true`:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@App({
  name: 'Billing',
  auth: {
    mode: 'local',
    consent: { enabled: true },
  },
})
export class BillingApp {}

@App({
  name: 'Analytics',
  auth: {
    mode: 'local',
  },
})
export class AnalyticsApp {}

@FrontMcp({
  info: { name: 'Suite', version: '1.0.0' },
  apps: [BillingApp, AnalyticsApp],
  splitByApp: true,
})
export class Server {}
```

***

## Token Storage

`tokenStorage` controls where authorization codes, refresh tokens, and federated-auth sessions are persisted. It is honored in local mode and supports three backends. The default (`'memory'`) loses all state on restart.

### In-Memory (Development)

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
auth: {
  mode: 'local',
  tokenStorage: 'memory', // default — lost on restart
}
```

### SQLite (Single-Node Persistence)

Persist tokens to a local SQLite file — survives restart without running Redis. `path` is required:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
auth: {
  mode: 'local',
  tokenStorage: {
    sqlite: {
      path: './data/auth.sqlite',
      encryption: { secret: process.env.AUTH_DB_SECRET! }, // optional at-rest encryption
      walMode: true,                 // default true
      ttlCleanupIntervalMs: 60000,   // default 60000
    },
  },
}
```

<Info>
  SQLite persistence requires the `@frontmcp/storage-sqlite` package (lazy-loaded — only needed when you use the `sqlite` backend).
</Info>

### Redis (Multi-Instance Persistence)

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
auth: {
  mode: 'local',
  tokenStorage: {
    redis: {
      host: process.env.REDIS_HOST!,
      port: parseInt(process.env.REDIS_PORT || '6379'),
      password: process.env.REDIS_PASSWORD,
      keyPrefix: 'myapp:auth:',
    },
  },
}
```

<Info>
  When a persistent backend is configured but fails to connect at startup, FrontMCP fails closed (throws) rather than silently falling back to memory — this avoids surprising token loss on restart.
</Info>

***

## Consent and Tool Authorization

Local mode supports a `consent` config that renders an **interactive tool-selection screen** during login and **enforces the selection at call time**. When `consent.enabled` is `true`:

1. After the user authenticates, `/oauth/callback` renders a **consent screen** (HTTP 200 HTML) listing the available tools.
2. The user's selection is GET-submitted back to `/oauth/callback` (carrying `pending_auth_id`, the resolved identity, and the checked `tools=`), and only then is the authorization code minted.
3. The chosen tool ids are embedded in the issued token's `consent` claim and **enforced on every `tools/call`** — a call to a tool the user did **not** select is rejected with a `TOOL_NOT_CONSENTED` error (JSON-RPC `-32003`, Forbidden).

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
auth: {
  mode: 'local',
  consent: {
    enabled: true,
    groupByApp: true,          // group tools under per-app cards (default)
    showDescriptions: true,    // show each tool's description (default)
    allowSelectAll: true,      // render the "select all" / "toggle all" controls (default)
    requireSelection: true,    // reject an empty submit and re-render with an error (default)
    customMessage: 'Choose the tools this client may use.', // optional subtitle override
    rememberConsent: true,     // reuse a prior per-(user, client) selection (default)
    excludedTools: ['ping'],   // never offered, never required — ALWAYS available
    defaultSelectedTools: ['create-note'], // pre-checked on the screen
  },
}
```

### Honored `ConsentConfig` flags

| Flag                   | Effect                                                                                                                                                                             |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`              | Turns the consent screen + runtime enforcement on. Default `false` (no screen, all tools allowed).                                                                                 |
| `groupByApp`           | `true` (default) groups tools under per-app cards; `false` renders a flat list.                                                                                                    |
| `showDescriptions`     | `true` (default) shows each tool's description; `false` hides them.                                                                                                                |
| `allowSelectAll`       | `true` (default) renders the select-all + per-app toggle controls; `false` removes them.                                                                                           |
| `requireSelection`     | `true` (default) rejects an empty submit by re-rendering with an error; `false` allows zero tools.                                                                                 |
| `customMessage`        | Replaces the default subtitle on the consent screen.                                                                                                                               |
| `rememberConsent`      | `true` (default) persists the user's selection per (user, client) and reuses it on the next login (re-prompting only when a NEW tool appears); `false` always re-shows the screen. |
| `excludedTools`        | Never offered on the screen and never required — these tools are **always available** regardless of the selection.                                                                 |
| `defaultSelectedTools` | Pre-checked when the screen first renders. When omitted, **all** tools are pre-checked.                                                                                            |

<Note>
  **Runtime enforcement is the security boundary.** The selected tool ids are
  carried as the token's `consent` claim and checked on every `tools/call`.
  Tokens minted WITHOUT consent (consent disabled, or tokens created directly
  via the test/programmatic factory) carry no `consent` claim and are
  unaffected — all tools remain callable. `excludedTools` are always allowed.
</Note>

<Note>
  **Federated (multi-provider) logins** show the consent screen AFTER the last
  provider is linked (in the provider-callback flow), then complete the mint —
  so consent applies uniformly to single-operator and federated logins.
</Note>

<Note>
  **`rememberConsent` (default `true`)** persists each user's per-client tool
  selection (keyed by `consent:{userSub}:{clientId}`) so a returning user is not
  re-prompted. On a later login: if every currently-available tool was already
  offered before, the screen is **skipped** and the token is minted with the
  remembered selection (intersected with what's still available, plus
  `excludedTools`). If a **new** tool has appeared since, the screen is re-shown
  **pre-filled** with the prior selection so the user explicitly decides about
  the new tool — a newly-added tool is never silently granted. The record stores
  only the opaque subject, client id, and tool ids (no PII), and shares the
  configured `tokenStorage` backend (in-memory by default; Redis/SQLite persists
  it across restarts). Set `rememberConsent: false` to always re-show the screen.
  Enforcement is unchanged — the minted token's `consent.selectedTools` claim
  still drives every `tools/call`.
</Note>

***

## Complete Example

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { FrontMcp } from '@frontmcp/sdk';

// JWT_SECRET signs tokens (HS256). Set a stable value so tokens survive restart.
@FrontMcp({
  info: { name: 'MyServer', version: '1.0.0' },
  auth: {
    mode: 'local',
    // Persist auth state across restarts (SQLite shown; { redis } also works).
    tokenStorage: {
      sqlite: {
        path: './data/auth.sqlite',
        encryption: { secret: process.env.AUTH_DB_SECRET! },
      },
    },
    // `refresh` governs proactive refresh of UPSTREAM/DOWNSTREAM provider
    // credentials (per-provider, refreshed `skewSeconds` before they expire).
    // It does NOT control this server's own access/refresh token lifetime.
    refresh: {
      enabled: true,
      skewSeconds: 60,
    },
    // Single operator (e.g. Claude Code): no email prompt, stable identity.
    requireEmail: false,
    anonymousSubject: 'local-operator',
    incrementalAuth: {
      enabled: true,
      allowSkip: true,
      skippedAppBehavior: 'require-auth',
    },
  },
})
export class Server {}
```

***

## Auth UI Template Builders

`@frontmcp/auth` ships server-side HTML template builders that render with no build step. The local authorize flow uses `buildLoginPage()` (and, for multi-provider/incremental flows, `buildFederatedLoginPage()` / `buildIncrementalAuthPage()`). The remaining builders are exported for custom use but are **not** wired into the built-in local flow today.

| Builder                      | Purpose                                     | Used by built-in local flow?                                                 |
| ---------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- |
| `buildLoginPage()`           | Email login form                            | Yes                                                                          |
| `buildFederatedLoginPage()`  | Multi-provider selection UI                 | Yes (federated/multi-app)                                                    |
| `buildIncrementalAuthPage()` | Single-app auth for previously skipped apps | Yes (incremental)                                                            |
| `buildConsentPage()`         | Multi-app authorization (per-app cards)     | No — not rendered                                                            |
| `buildToolConsentPage()`     | Tool-selection consent screen               | Yes (when `consent.enabled`; see [Consent](#consent-and-tool-authorization)) |
| `buildErrorPage()`           | Error display with description              | Yes (error paths)                                                            |

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="Tokens invalid after restart">
    Two independent causes:

    * **Signing secret rotated.** When `JWT_SECRET` is unset, a random per-process secret is used, so every restart invalidates tokens. Set a stable `JWT_SECRET`.
    * **In-memory token storage.** Authorization codes / refresh tokens live in memory by default. Use `tokenStorage: { sqlite: { path } }` (single node) or `{ redis }` (multi-instance) to persist them.
  </Accordion>

  <Accordion title="PKCE verification failed">
    Ensure you're using S256 challenge method and the code\_verifier matches the original code\_challenge.
  </Accordion>

  <Accordion title="Discovery / issuer wrong behind a tunnel">
    Discovery is request-host-derived, so it usually works behind a proxy automatically. If the token `iss` must match a public HTTPS URL, set `local.issuer` (full URL) or `FRONTMCP_PUBLIC_HOST` (host only). See [Behind a Tunnel](#behind-a-tunnel-or-reverse-proxy).
  </Accordion>

  <Accordion title="Session not found across instances">
    When running multiple server instances, use `tokenStorage: { redis }` to share auth state.
  </Accordion>
</AccordionGroup>

***

## Next Steps

<CardGroup cols={2}>
  <Card title="Remote OAuth" icon="cloud" href="/frontmcp/authentication/remote">
    Connect to external identity providers
  </Card>

  <Card title="Progressive Authorization" icon="forward" href="/frontmcp/authentication/progressive">
    Implement incremental app authorization
  </Card>

  <Card title="Production Checklist" icon="clipboard-check" href="/frontmcp/authentication/production">
    Security requirements for deployment
  </Card>

  <Card title="Tokens & Sessions" icon="key" href="/frontmcp/authentication/token">
    Configure token lifetimes
  </Card>
</CardGroup>
