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 theJWT_SECRET environment variable:
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
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
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) |
requireEmail | boolean | true | Require an email at the login callback (see Single-Operator) |
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 |
authenticate | (input, ctx) => Promise<AuthenticateResult> | unset | Custom verification step run at the login callback before a token is minted — see Custom Login & Verification |
consent | ConsentConfig | unset | Consent / tool-authorization config — the block is optional; when present, inner enabled defaults to false (see Consent) |
providers | UpstreamProviderOptions[] | unset | Upstream OAuth providers to orchestrate (see Multi-Provider Orchestration) |
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) |
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
Token Exchange
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
Whenfederated=true and providers are selected:
- A federated session is created to track progress
- PKCE is generated for the first provider
- User is redirected to the first provider’s authorization URL
- After each provider completes, the chain continues to the next
- Once all providers complete, an authorization code is created
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).
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 |
${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 |
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:
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 stayshttpand 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. advertisehttps://mcp.example.comwith no explicit port behind a TLS-terminating proxy).
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:
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.
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:
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:
input.fields— the submitted login fields (built-inemail/nameplus anylogin.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? }. Usectx.getto resolve DI services andctx.fetchfor outbound verification.- On
{ ok: true }— a token is minted. The subject isresult.sub, else thelogin.subjectstrategy (per-accounthashesfromField), else theanonymousSubjectfallback.result.claimsare embedded in the access token. - On
{ ok: false }— the login page is re-rendered withmessageshown and submitted values preserved; no code is issued. UseretryFieldto hint which field to correct.
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.
this.credentials:
Vault behavior and security
- At-rest encryption — credentials are sealed with AES-256-GCM via the shared
VaultEncryptionprimitive. The per-record key is derived (HKDF) from a fresh per-sessionvaultIdplus a pepper fromVAULT_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 sameauth.tokenStoragebackend (memory / Redis / SQLite), so it is persistent wherever your token stores are. - No PII — only the credential
secret/metadatayou 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:
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:
this.secureStore:
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) |
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 authenticatedsub. Survives a reconnect for the same user. Anonymous requests get no namespace (reads return empty, writes are skipped).session: keyed by the transportsessionId. 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).
(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.
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_ids 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. |
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
Registration Response
A successful registration responds201 Created:
Per-App Configuration
Configure local auth per app withsplitByApp: true:
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)
SQLite (Single-Node Persistence)
Persist tokens to a local SQLite file — survives restart without running Redis.path is required:
SQLite persistence requires the
@frontmcp/storage-sqlite package (lazy-loaded — only needed when you use the sqlite backend).Redis (Multi-Instance Persistence)
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.
Consent and Tool Authorization
Local mode supports aconsent config that renders an interactive tool-selection screen during login and enforces the selection at call time. When consent.enabled is true:
- After the user authenticates,
/oauth/callbackrenders a consent screen (HTTP 200 HTML) listing the available tools. - The user’s selection is GET-submitted back to
/oauth/callback(carryingpending_auth_id, the resolved identity, and the checkedtools=), and only then is the authorization code minted. - The chosen tool ids are embedded in the issued token’s
consentclaim and enforced on everytools/call— a call to a tool the user did not select is rejected with aTOOL_NOT_CONSENTEDerror (JSON-RPC-32003, Forbidden).
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. |
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
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) |
buildErrorPage() | Error display with description | Yes (error paths) |
Troubleshooting
Tokens invalid after restart
Tokens invalid after restart
Two independent causes:
- Signing secret rotated. When
JWT_SECRETis unset, a random per-process secret is used, so every restart invalidates tokens. Set a stableJWT_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.
PKCE verification failed
PKCE verification failed
Ensure you’re using S256 challenge method and the code_verifier matches the original code_challenge.
Discovery / issuer wrong behind a tunnel
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.Session not found across instances
Session not found across instances
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