# Authenticating Clients

> How MCP clients prove who they are to a FrontMCP server. The five auth modes, what a rejected request gets back, and how to choose.

Source: https://frontmcp.dev/learn/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:

```ts whoami.tool.ts active
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 };
  }
}
```

```ts main.ts
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: "public" },
})
export default class Server {}
```

```ts whoami.test.ts
import { test, expect } from "@frontmcp/testing";

test("every request is a new anonymous caller", async ({ mcp }) => {
  const first = (await mcp.tools.call("whoami", {})).json();
  const second = (await mcp.tools.call("whoami", {})).json();

  expect(first.caller).toMatch(/^anon:/);
  expect(second.caller).toMatch(/^anon:/);
  expect(second.caller).not.toBe(first.caller);
  expect(first.scopes).toEqual(["anonymous"]);
});
```

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](https://frontmcp.dev/learn/authorizing-calls) 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](https://frontmcp.dev/learn/limiting-calls) later in this chapter count all anonymous callers together, as one. It matters for anything kept per caller too: by default, the [Cache plugin](https://frontmcp.dev/learn/caching-results#turning-the-cache-on) 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`:

```text
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.

> **Note**
The Playground's client doesn't send an `Authorization` header, so a server that requires one refuses every request the Playground makes, including the `tools/list` it needs to start. The examples below that need credentials are plain code, with the responses FrontMCP 1.9.3 sends. Run them in your own project to try them.

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

```ts 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:

```text
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:

```text
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. |

> **Pitfall: A shared key identifies a key, not a person**
Everyone with the same key is the same caller: one `static:…` id, the same scopes. There's no way to tell users apart or to revoke one of them. To rotate a key, deploy with both the old and the new key in `tokens`; each gets its own id. Then remove the old one. When you need to know which person is calling, use tokens from an identity provider.

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

```ts 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:

| 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](https://frontmcp.dev/reference/auth/modes#the-servers-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.

> **Note**
FrontMCP finds the provider's signing keys by itself. It tries `<provider>/.well-known/jwks.json`, then the `jwks_uri` that the provider lists in its `/.well-known/oauth-authorization-server` or OpenID `/.well-known/openid-configuration` metadata. If your provider keeps its keys somewhere else, set `providerConfig.jwksUri` to its JWKS URL, or write the keys into your config as `providerConfig.jwks`. A server that finds no keys refuses every token with `no_provider_verified`. [Tokens and sessions](https://frontmcp.dev/reference/auth/tokens#where-the-keys-come-from) shows each request FrontMCP makes.

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

```ts main.ts active
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 {}
```

```ts whoami.tool.ts
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 };
  }
}
```

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](https://frontmcp.dev/learn/authorizing-calls).

> **Note**
`anonymousScopes` works in `public` mode too, in place of the scope `anonymous`. And every mode accepts `publicAccess`, which limits what anonymous callers may use. With `publicAccess: { tools: ["search_tickets"] }`, every other tool is left out of an anonymous caller's `tools/list` and refused when called by name; `prompts` does the same for prompts, and anonymous calls are limited to `rateLimit` a minute per IP address, 60 by default. Signed-in callers aren't affected, and `publicAccess` doesn't let anyone in: a `static` server still refuses requests without a key. [Auth modes](https://frontmcp.dev/reference/auth/modes#limiting-what-anonymous-callers-can-use) shows both.

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

```ts 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](https://frontmcp.dev/reference/auth/local), [Custom login UI](https://frontmcp.dev/reference/auth/login-ui), [Remote and proxied auth](https://frontmcp.dev/reference/auth/remote), and [Client ID metadata](https://frontmcp.dev/reference/auth/cimd) 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 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: 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.

```ts add-note.tool.ts
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 };
  }
}
```

```ts add-note.tool.ts solution
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. The note is signed with the caller's identity.",
  inputSchema: {
    ticket: z.string().describe("Ticket id, like T-1"),
    text: z.string().min(1),
  },
})
export class AddNote extends ToolContext {
  async execute({ ticket, text }: { ticket: string; text: string }) {
    const note = { ticket, text, author: this.auth.user.sub };
    notes.push(note);
    return { note };
  }
}
```

```ts add-note.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("`add_note` has no `author` argument", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t) => t.name === "add_note");
  expect(tool?.inputSchema.properties ?? {}).not.toHaveProperty("author");
});

test("the note is signed with the caller's identity", async ({ mcp }) => {
  const result = await mcp.tools.call("add_note", { ticket: "T-1", text: "Called the customer back" });
  expect(result).toBeSuccessful();
  expect(result.json().note.author).toMatch(/^anon:/);
});

test("a name in the arguments doesn't change the author", async ({ mcp }) => {
  const result = await mcp.tools.call("add_note", { ticket: "T-1", text: "Refund approved", author: "Nour" });
  expect(result).toBeSuccessful();
  expect(result.json().note.author).not.toBe("Nour");
});
```

**Hint:**
`this.auth.user.sub` is the caller FrontMCP authenticated from the request's header.

**Solution:**
With `author` gone from `inputSchema`, the model has no way to name an author, and FrontMCP drops an `author` argument if one is sent anyway. The author now comes from `this.auth.user.sub`: `anon:…` here, `static:…` with a shared key, or the token's `sub` behind an identity provider. Only the credentials in the header decide it.

### Challenge: Let anonymous callers read
The help desk wants signed-in agents to use tokens from `https://auth.example.com`, and everyone else to be able to search tickets without signing in. `search_tickets` already requires the `tickets:read` scope, and `close_ticket` requires `tickets:write`. Don't change the tools: change the server's `auth` so that requests without a token get in with `tickets:read`, and nothing more.

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket, SearchTickets } from "./tickets.tools";

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, CloseTicket] })
class HelpDeskApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDeskApp],
  auth: { mode: "public" },
})
export default class Server {}
```

```ts main.ts solution
import { App, FrontMcp } from "@frontmcp/sdk";
import { CloseTicket, SearchTickets } from "./tickets.tools";

@App({ id: "help-desk", name: "Help Desk", tools: [SearchTickets, CloseTicket] })
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 {}
```

```ts tickets.tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

const tickets = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-2", title: "Invoice total is wrong", status: "open" },
  { id: "T-3", title: "Login link expired", status: "open" },
];

@Tool({
  name: "search_tickets",
  description: "Search support tickets by words in their title. Needs the tickets:read scope.",
  inputSchema: { query: z.string().min(1) },
  annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    if (!this.auth.hasScope("tickets:read")) {
      this.fail(new PublicMcpError("Searching tickets needs the tickets:read scope.", "FORBIDDEN"));
    }
    const q = query.toLowerCase();
    return { tickets: tickets.filter((t) => t.title.toLowerCase().includes(q)) };
  }
}

@Tool({
  name: "close_ticket",
  description: "Close a support ticket. Needs the tickets:write scope, which signed-in agents have.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (!this.auth.hasScope("tickets:write")) {
      this.fail(new PublicMcpError("Closing tickets needs the tickets:write scope. Ask the user to sign in as an agent.", "FORBIDDEN"));
    }
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    ticket.status = "closed";
    return { id, closed: true };
  }
}
```

```ts auth.test.ts hidden
import { test, expect } from "@frontmcp/testing";

test("anyone can search without a token", async ({ mcp }) => {
  const result = await mcp.tools.call("search_tickets", { query: "log" });
  expect(result).toBeSuccessful();
  expect(result.json().tickets.map((t: { id: string }) => t.id)).toEqual(["T-1", "T-3"]);
});

test("anonymous callers still can't close tickets", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result).toBeError();
  expect(result.raw._meta?.code).toBe("FORBIDDEN");
});
```

**Hint:**
Public mode can give anonymous callers `tickets:read` with `anonymousScopes`, but it refuses the agents' tokens: it only accepts tokens it issued itself. The mode that checks tokens from a provider can also let requests without a token in, with the scopes you choose.

**Solution:**
`mode: "transparent"` with `provider` checks agents' tokens against `https://auth.example.com`. `allowAnonymous: true` lets requests without a token in, and `anonymousScopes: ["tickets:read"]` gives them exactly the scope `search_tickets` needs and not the one `close_ticket` needs. Public mode with `anonymousScopes: ["tickets:read"]` passes these checks too, but it refuses every JWT it didn't sign, so the agents couldn't use their tokens.
