Skip to main content
FrontMCP includes a built-in OAuth 2.1 authorization server for self-contained authentication scenarios.
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.

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:
JWT_SECRET=$(openssl rand -hex 32)
@FrontMcp({
  info: { name: 'MyServer', version: '1.0.0' },
  auth: { mode: 'local' },
})
export class Server {}
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.
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

@FrontMcp({
  info: { name: 'MyServer', version: '1.0.0' },
  auth: {
    mode: 'local',
  },
})
export class Server {}

Configuration Options

OptionTypeDefaultDescription
tokenStorage'memory' | { redis: RedisConfig } | { sqlite: SqliteConfig }'memory'Token storage backend (see 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)
requireEmailbooleantrueRequire an email at the login callback (see Single-Operator)
anonymousSubjectstring'local-operator'Stable sub minted when requireEmail: false and no email is provided
loginLoginConfigunsetCustomize the built-in login page (title, fields, full HTML override, subject strategy) — see Custom Login & Verification
authenticate(input, ctx) => Promise<AuthenticateResult>unsetCustom verification step run at the login callback before a token is minted — see Custom Login & Verification
consentConsentConfigunsetConsent / tool-authorization config — the block is optional; when present, inner enabled defaults to false (see Consent)
providersUpstreamProviderOptions[]unsetUpstream OAuth providers to orchestrate (see Multi-Provider Orchestration)
federatedAuthFederatedAuthConfigunsetFederated-login gating: minProviders, requiredProviders, stateValidation
allowDefaultPublicbooleanfalseAllow unauthenticated access
anonymousScopesstring[]['anonymous']Scopes for anonymous sessions
local.issuerstringauto-derivedOverride the issuer (see Behind a Tunnel)
incrementalAuthIncrementalAuthConfigunsetProgressive authorization config — the block is optional; when present, inner enabled defaults to true

OAuth Endpoints

Local mode exposes standard OAuth 2.1 endpoints:
EndpointMethodDescription
/oauth/authorizeGETStart authorization flow
/oauth/tokenPOSTExchange code for tokens
/oauth/registerPOSTDynamic Client Registration
/oauth/userinfoGETGet user profile (verifies the Bearer token; 401 if missing/invalid)
/oauth/connectGET/POSTMid-session credential-connect flow for this.credentials (connect additional credentials to the current identity)
/.well-known/oauth-authorization-serverGETAuthorization-server metadata (AS)
/.well-known/oauth-protected-resourceGETProtected-resource metadata (PRM)
/.well-known/jwks.jsonGETPublic 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

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:
StageDescription
parseInputExtract query parameters (email, pending_auth_id, etc.)
validatePendingAuthVerify pending auth record and validate providers
handleIncrementalAuthProcess progressive authorization for apps
handleFederatedAuthChain through federated identity providers
createAuthorizationCodeGenerate authorization code with consent data
redirectToClientRedirect user back to client with code

Callback Query Parameters

ParameterDescription
pending_auth_idReference to pending authorization record
emailUser’s email address
nameUser’s display name (optional)
incrementalSet to true for progressive auth (optional)
app_idTarget app for incremental authorization (optional)
federatedSet to true for federated login (optional)
providersSelected provider IDs (optional, array or string)
toolsSelected 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
Selected provider IDs are validated against the pending authorization’s allowed providers. Invalid provider IDs are rejected with a 400 error.

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).
@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)

FieldTypeRequiredDescription
idstringyesStable provider id used in this.orchestration.getToken(id)
authorizationEndpointstringyes*Authorization endpoint (alias: authorizeUrl)
tokenEndpointstringyes*Token endpoint (alias: tokenUrl)
clientIdstringyesOAuth client id from the upstream provider
clientSecretstringnoOAuth client secret (confidential clients)
scopesstring[]noScopes to request (defaults to [])
namestringnoDisplay name (defaults to id)
userInfoEndpointstringnoUser-info endpoint to enrich the session identity
jwksUristringnoJWKS 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:
FieldTypeDefaultDescription
minProvidersnumber1 (when providers)Refuse to mint a JWT until at least this many providers are linked
requiredProvidersstring[]unsetEvery 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:
@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.
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.

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).
auth: {
  mode: 'local',
  local: {
    // Aligns the token `iss` with what clients reach through the proxy.
    issuer: 'https://mcp.example.com',
  },
}
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.

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:
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.
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 instead of (or alongside) the declarative login fields.

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:
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:
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.
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:
@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

MethodReturnsDescription
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:
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:
@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:
@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

BackingUse casePersistenceAt-rest encryption
'memory' (default)Local dev, ephemeral CI, no RedisProcess lifetimeAES-256-GCM, HKDF(scope + server pepper)
{ sqlite: { path } }Single-host persistence, no RedisSurvives restartSame VaultEncryption over the SQLite-file adapter
{ redis: {...} }Multi-instance / serverless persistenceSurvives restartSame VaultEncryption over the Redis adapter
{ backend }OS keychain or any custom storeOwned by backendOwned 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

MethodReturnsDescription
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).
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.
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.

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.
@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 fieldTypeDefaultEffect
enabledbooleandev: on, prod: offWhen false, /oauth/register returns 404 and registration_endpoint is omitted from AS metadata.
allowedRedirectUrisstring[](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).
allowedClientIdsstring[](none)Only these client_ids may be used at /oauth/authorize. CIMD URL client ids are validated by CIMD instead and are exempt.
initialAccessTokenstring(none)When set, /oauth/register requires a matching Authorization: Bearer <token> (constant-time compared) → 401 otherwise.
clientsLocalDcrClient[](none)Pre-registered trusted clients seeded at startup; accepted by authorize/token without a DCR round-trip.
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.

Registration Request

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:
{
  "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"]
}
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.

Per-App Configuration

Configure local auth per app with splitByApp: true:
@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)

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:
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
    },
  },
}
SQLite persistence requires the @frontmcp/storage-sqlite package (lazy-loaded — only needed when you use the sqlite backend).

Redis (Multi-Instance Persistence)

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:',
    },
  },
}
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.

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).
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

FlagEffect
enabledTurns the consent screen + runtime enforcement on. Default false (no screen, all tools allowed).
groupByApptrue (default) groups tools under per-app cards; false renders a flat list.
showDescriptionstrue (default) shows each tool’s description; false hides them.
allowSelectAlltrue (default) renders the select-all + per-app toggle controls; false removes them.
requireSelectiontrue (default) rejects an empty submit by re-rendering with an error; false allows zero tools.
customMessageReplaces the default subtitle on the consent screen.
rememberConsenttrue (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.
excludedToolsNever offered on the screen and never required — these tools are always available regardless of the selection.
defaultSelectedToolsPre-checked when the screen first renders. When omitted, all tools are pre-checked.
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.
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.
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.

Complete Example

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.
BuilderPurposeUsed by built-in local flow?
buildLoginPage()Email login formYes
buildFederatedLoginPage()Multi-provider selection UIYes (federated/multi-app)
buildIncrementalAuthPage()Single-app auth for previously skipped appsYes (incremental)
buildConsentPage()Multi-app authorization (per-app cards)No — not rendered
buildToolConsentPage()Tool-selection consent screenYes (when consent.enabled; see Consent)
buildErrorPage()Error display with descriptionYes (error paths)

Troubleshooting

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.
Ensure you’re using S256 challenge method and the code_verifier matches the original code_challenge.
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.
When running multiple server instances, use tokenStorage: { redis } to share auth state.

Next Steps

Remote OAuth

Connect to external identity providers

Progressive Authorization

Implement incremental app authorization

Production Checklist

Security requirements for deployment

Tokens & Sessions

Configure token lifetimes