Authenticating Clients

IntermediateMCP 2026-07-28

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:

Open
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:

main.ts
@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.

OptionDefaultWhat it does
tokensrequiredThe 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:

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

RequestStatusWWW-Authenticate
No token401Bearer resource_metadata="https://desk.example.com/.well-known/oauth-protected-resource"
Not a JWT401The 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 match401The same, plus error="invalid_token", error_description="no_provider_verified (kid=…)"
No sub, client_id or azp: it doesn't say who is calling401The same, plus error="invalid_token" and a description naming the missing claims
Issued for another audience401The same, plus error="invalid_token" and a description naming both audiences
Missing a scope in requiredScopes403The 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:

Open
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 your clientId and clientSecret there) 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.
main.ts
@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

ModeWho gets inWho your tools seeUse it when
publicEveryoneanon:…, new on every request, scope anonymousLocal development, or only public data
staticClients that send one of your keysstatic:…, one id per keyYou configure every client yourself: an internal agent, a host's "API key" setting
transparentClients with a valid token from your identity providerThe token's sub, scopes and claimsYour users already sign in with a provider that issues JWTs
localUsers who sign in on FrontMCP's own pageThe signed-in userThere's no identity provider, and each user should have an identity
remoteUsers who sign in with an upstream provider, through FrontMCPThe signed-in userYour 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 is public: every request gets in as anon:…, with a new id each time under 2026-07-28.
  • Credentials travel in the Authorization header of every request, set by the client. Never take identity from tool arguments.
  • static checks a shared key from tokens. Every caller with the same key is the same static:… caller.
  • transparent verifies JWTs from provider: signature, issuer, expiry and audience, plus requiredScopes. allowAnonymous lets requests without a token in with anonymousScopes.
  • A refused request gets 401 (or 403 for missing scopes) with a WWW-Authenticate header, before any tool runs. The model never sees it.
  • local and remote make 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.

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