Authenticating Clients
Authentication answers one question: who is calling? An MCP client answers it with a credential in the Authorization header of every request, and FrontMCP checks that credential before any tool runs. Which credentials count, and what happens to a request without one, depend on the server's auth mode. FrontMCP has five: public, static, transparent, local and remote.
You will learn
- Who is calling when a request carries no credentials
- Where credentials travel, and why they never go in tool arguments
- How to protect a server with a shared key (
static) - How to accept tokens from your identity provider (
transparent) - What a client gets back when it isn't let in, and how to choose a mode
A server anyone can call
Every server so far in this course has had no auth option, which means mode: "public": every request is let in. Here the mode is spelled out, and whoami reports who FrontMCP thinks is calling:
import { Tool, ToolContext } from "@frontmcp/sdk";
@Tool({
name: "whoami",
description: "Say who the server thinks is calling, and with which scopes.",
inputSchema: {},
annotations: { readOnlyHint: true },
})
export class WhoAmI extends ToolContext {
async execute() {
return { caller: this.auth.user.sub, scopes: this.auth.scopes };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The caller is anon: followed by a random id, with one scope, anonymous. this.auth is where a tool finds out who is calling; the next lesson covers what's in it.
Now open the Tests tab. Call whoami twice and you get two different ids. Under MCP 2026-07-28 there are no sessions: each request is authenticated on its own, and a request without credentials is a new stranger every time. Your server can't tell that two anonymous calls came from the same client. That matters for anything that counts per caller: the rate limits later in this chapter count all anonymous callers together, as one. It matters for anything kept per caller too: by default, the Cache plugin never answers an anonymous caller from its cache.
Public mode is right on your laptop and for a server that only exposes public data. On a server that can reach your tickets, anyone who finds the URL can call every tool.
Where credentials travel
A client sends its credential in the HTTP Authorization header, on every request, including tools/list:
POST / HTTP/1.1
Host: desk.example.com
Authorization: Bearer 9c1f4e7a…
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: close_ticket
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"close_ticket","arguments":{"id":"T-1"}}}
The header is set by the client application, from what the user configured or signed in with. The model never sees it and can't change it. Tool arguments are the opposite: the model writes them, and it can write anything. So a tool should never learn who is calling from an argument like agent or userId. It reads the identity FrontMCP established from the header. The first challenge below fixes a tool that gets this wrong.
Requiring a shared key: static mode
The smallest step up from public is one secret that every legitimate client sends. Many MCP hosts have a setting for exactly this, often labelled "API key" or "access token", that adds a fixed Authorization: Bearer … header to every request. mode: "static" checks it:
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
auth: { mode: "static", tokens: [process.env.DESK_API_KEY!] },
})
export default class Server {}A request without the header, or with a key that isn't in tokens, doesn't reach any tool:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="mcp"
Content-Type: application/json; charset=utf-8
{"error":"Unauthorized"}
A wrong key gets the same status, with the reason in the challenge:
WWW-Authenticate: Bearer realm="mcp", error="invalid_token", error_description="The access token is invalid"
This is an HTTP response, not a JSON-RPC error or a tool result. The model never sees it; the client does, and the fix is in the client's settings. A request with the right key gets through, and its caller is static: followed by 12 hex characters derived from the key, with the scope static. The key itself never reaches your tools: it's in neither this.auth nor this.context.authInfo.
| Option | Default | What it does |
|---|---|---|
tokens | required | The accepted keys. Compared in constant time. |
header | "authorization" | The request header that carries the key, like "x-api-key". |
scheme | "Bearer" | The prefix before the key, matched without regard to case. "" for a header that holds the bare key. |
scopes | ["static"] | The scopes an accepted request gets. |
realm | "mcp" | The realm in the WWW-Authenticate challenge. |
Accepting tokens from your identity provider
If your users already sign in with an identity provider, like Auth0, Okta or Keycloak, the client can get an access token (a signed JWT) from it and send that. mode: "transparent" checks those tokens and doesn't issue any of its own:
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
auth: {
mode: "transparent",
provider: "https://auth.example.com",
expectedAudience: "https://desk.example.com",
requiredScopes: ["tickets:read"],
},
})
export default class Server {}For each request, FrontMCP checks that the token is signed by one of the provider's keys, that its issuer (iss) is provider, that it hasn't expired, and, if it names an audience (aud), that the audience is expectedAudience. The caller is then the token's sub, its scopes come from the token's scope claim (or scp, which some providers use instead), and the rest of its claims are available to your tools. A request that fails gets a 401 or 403 that says why:
| Request | Status | WWW-Authenticate |
|---|---|---|
| No token | 401 | Bearer resource_metadata="https://desk.example.com/.well-known/oauth-protected-resource" |
| Not a JWT | 401 | The same, plus error="invalid_token", error_description="Token is not a valid JWT" |
| Expired, wrong issuer, or a signature the provider's keys don't match | 401 | The same, plus error="invalid_token", error_description="no_provider_verified (kid=…)" |
No sub, client_id or azp: it doesn't say who is calling | 401 | The same, plus error="invalid_token" and a description naming the missing claims |
| Issued for another audience | 401 | The same, plus error="invalid_token" and a description naming both audiences |
Missing a scope in requiredScopes | 403 | The same, plus error="insufficient_scope" and scope="tickets:read" |
The URLs in these headers start with the server's address. FrontMCP's Node server builds it from http:// and the request's Host header, so behind a proxy that terminates TLS they say http://; set FRONTMCP_PUBLIC_URL=https://desk.example.com to get the addresses above. The server's public address has the details.
resource_metadata points at a document FrontMCP serves, which tells an MCP client where to get a token. That's how a client that has never seen your server knows to start a sign-in instead of just failing.
Letting anonymous callers in, with less
A token-only server shuts out everyone who hasn't signed in. allowAnonymous: true lets requests without a token in, as anonymous callers with the scopes in anonymousScopes. Requests that do carry a token are still checked, and a bad token is still refused. Because an anonymous request needs no header, this one runs in the Playground:
import { App, FrontMcp } from "@frontmcp/sdk";
import { WhoAmI } from "./whoami.tool";
@App({ id: "help-desk", name: "Help Desk", tools: [WhoAmI] })
class HelpDeskApp {}
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
auth: {
mode: "transparent",
provider: "https://auth.example.com",
allowAnonymous: true,
anonymousScopes: ["tickets:read"],
},
})
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The caller is still anon:…, now with the scope tickets:read. A signed-in support agent would arrive with the scopes in their token, like tickets:read tickets:write. Letting each tool decide what those scopes allow is the subject of the next lesson.
Letting FrontMCP run the sign-in
transparent assumes someone else issues the tokens. In the other two modes, FrontMCP is itself an OAuth 2.1 authorization server: MCP clients register with it, send the user to sign in, and get tokens FrontMCP signed.
local: FrontMCP shows its own sign-in page and issues tokens for the users who complete it. For a server with no identity provider behind it.remote: FrontMCP sends the user to an upstream provider (provider, with yourclientIdandclientSecretthere) to sign in, then issues its own token for that identity. For providers that MCP clients can't register with directly, or when you want FrontMCP's consent screen between the user and your tools.
@FrontMcp({
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
auth: {
mode: "remote",
provider: "https://auth.example.com",
clientId: process.env.DESK_CLIENT_ID!,
clientSecret: process.env.DESK_CLIENT_SECRET!,
},
})
export default class Server {}To a client without a token, both answer like transparent: a 401 with resource_metadata, except that the document it points to names FrontMCP itself as the place to get a token. The tokens they issue are kept in memory by default (tokenStorage: "memory"), so they're lost on restart and not shared between instances; tokenStorage also takes { redis } or { sqlite }. Their sign-in pages, consent screens and storage have many options of their own, beyond this lesson: see Local auth, Custom login UI, Remote and proxied auth, and Client ID metadata for clients that identify themselves by a URL.
Choosing a mode
| Mode | Who gets in | Who your tools see | Use it when |
|---|---|---|---|
public | Everyone | anon:…, new on every request, scope anonymous | Local development, or only public data |
static | Clients that send one of your keys | static:…, one id per key | You configure every client yourself: an internal agent, a host's "API key" setting |
transparent | Clients with a valid token from your identity provider | The token's sub, scopes and claims | Your users already sign in with a provider that issues JWTs |
local | Users who sign in on FrontMCP's own page | The signed-in user | There's no identity provider, and each user should have an identity |
remote | Users who sign in with an upstream provider, through FrontMCP | The signed-in user | Your provider doesn't work with MCP clients directly, or you want FrontMCP's consent screen |
Start with the simplest mode that tells you what your tools need to know. If a tool only needs to know "one of ours", a shared key is enough. If it needs to know which agent is closing a ticket, it needs tokens.
Recap
- Without
auth, a server ispublic: every request gets in asanon:…, with a new id each time under 2026-07-28. - Credentials travel in the
Authorizationheader of every request, set by the client. Never take identity from tool arguments. staticchecks a shared key fromtokens. Every caller with the same key is the samestatic:…caller.transparentverifies JWTs fromprovider: signature, issuer, expiry and audience, plusrequiredScopes.allowAnonymouslets requests without a token in withanonymousScopes.- A refused request gets
401(or403for missing scopes) with aWWW-Authenticateheader, before any tool runs. The model never sees it. localandremotemake FrontMCP an OAuth server that signs users in, on its own page or through an upstream provider.
Try some challenges
Each challenge runs hidden checks against your code. Edit the code, then press Check.
Challenge 1 of 2
Take the author from the credentials
add_note records who wrote a note from an author argument, so the model can sign a note with any name it likes: the call below signs it "Nour". Remove the argument, and record the caller FrontMCP authenticated instead. In the Playground, that's an anon: id.
import { Tool, ToolContext, z } from "@frontmcp/sdk";
const notes: { ticket: string; text: string; author: string }[] = [];
@Tool({
name: "add_note",
description: "Add an internal note to a support ticket.",
inputSchema: {
ticket: z.string().describe("Ticket id, like T-1"),
text: z.string().min(1),
author: z.string().describe("Who is writing the note"),
},
})
export class AddNote extends ToolContext {
async execute({ ticket, text, author }: { ticket: string; text: string; author: string }) {
const note = { ticket, text, author };
notes.push(note);
return { note };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.