1. What this lecture is about and what it is not
This will be a very interesting lecture; in this lecture we will:
- assemble the mental picture of the “triangle of trust” among MCP Client, MCP Server, and MCP Auth Server — with the human user who stands “above” this triangle as the resource owner;
- walk through the flow: who sends a token to whom, where the user logs in, and why the MCP server never sees their password;
- tie this to our Next.js/MCP backend and the upcoming Keycloak/Auth0 configuration.
What we will not do today:
- we will not click checkboxes in Keycloak or configure a specific IdP;
- we will not write full JWT verification or introspection — these are topics of the next lectures (about the Auth Server and about the MCP Server as a protected resource).
The goal now is that you can take a sheet of paper, draw arrows between ChatGPT, your server, and Auth0/Keycloak, and fluently explain: where the login is, where the token is, and where the data is.
2. The triangle of trust: MCP Client, MCP Server, MCP Auth Server
Let’s start with the actors. The technical “triangle of trust” is formed by MCP Client, MCP Server, and MCP Auth Server; the user (User) is a separate role — the resource owner — who stands above this triangle and grants consent for access. In the MCP and Apps SDK context this architecture is formalised quite clearly.
User (Resource Owner)
This is the person on the other side of the screen. They:
- open ChatGPT;
- enter a prompt like “show my orders / my gift lists”;
- consent to “link the account” of your service to ChatGPT.
The key point: they own the resources (order history, profiles, gift lists) and they grant access to them.
MCP Client
For us here this is:
- ChatGPT with Apps SDK;
- sometimes — MCP Jam Inspector (during debugging).
MCP Client can:
- read the metadata of your MCP server (via .well-known);
- initiate the OAuth flow in the user’s browser;
- store and attach tokens to MCP tool calls.
It is important to remember that MCP Client is a public client. It does not store your client_secret, so it talks to the Auth Server as a public SPA application: Authorization Code + PKCE.
MCP Server (Resource Server)
This is your backend implementing MCP:
- establishes a connection with ChatGPT;
- declares tools (tools), resources, prompts;
- for each tool call, inspects the Authorization: Bearer <token> header;
- validates the token (signature, exp, aud, scope) and, if everything is OK, executes business logic.
A key point: the MCP server does not handle login. It never sees passwords, does not draw a login form, and does not send a “confirm your email” message to the user. It trusts only cryptographically signed tokens from the Auth Server.
MCP Auth Server (Authorization Server / IdP)
This is a separate authentication and authorization service: Keycloak, Auth0, Ory Hydra+Kratos, Okta, Cognito, Azure AD, etc.
It is responsible for:
- login UI (email/password, SSO, 2FA);
- storing user accounts;
- issuing tokens (access token, refresh token);
- publishing OAuth/OIDC metadata (/authorize, /token, jwks_uri, /registration, etc.).
For MCP it should support OAuth 2.1 for public clients (PKCE S256, dynamic client registration, etc.).
Summary table of roles
| Who | What it does | What it does not do |
|---|---|---|
| User | Enters username/password, grants consent to access data | Does not interact directly with the MCP Server |
| MCP Client (ChatGPT/Jam) | Initiates OAuth, stores token, calls MCP tools | Does not check passwords, does not verify the token signature |
| MCP Server | Verifies tokens, runs tools’ business logic | Does not render a login form, does not store passwords |
| MCP Auth Server | Logs the user in, issues tokens | Does not know about your MCP tools and their business logic |
If in your head this all merged into one “big server that does everything” — it is time to separate.
3. What the flow looks like: from “no token” to a protected tool call
Now let’s look at the message flow. In the MCP specification this is called “The Flow”: discovery → redirect → code → token → authorized calls.
Step 0. Attempt to call a protected tool without a token
The user writes: “Show my saved gift ideas.”
As the MCP Client, ChatGPT decides: “for this we need to call the getUserGiftLists tool on our MCP server.” It makes the call without a token (the user has not logged in yet).
Your MCP server:
- sees a missing or incorrect Authorization header;
- responds with 401 Unauthorized and adds the header WWW-Authenticate: Bearer resource_metadata="https://api.giftgenius.com/.well-known/oauth-protected-resource" containing a link to the protected resource metadata (more on this below).
This looks roughly like this (logic, not full HTTP):
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.giftgenius.com/.well-known/oauth-protected-resource"
ChatGPT reads this header and understands: “aha, the resource is protected by OAuth; we need to run the OAuth flow and link an account.”
Discovery: .well-known/oauth-protected-resource
Next the MCP Client requests metadata from your server:
GET /.well-known/oauth-protected-resource
The server responds with a JSON document containing the resource identifier and the list of authorization servers from which to obtain tokens.
Minimal example (we will configure this in detail later; the idea is what matters now):
{
"resource": "https://api.giftgenius.com",
"authorization_servers": [
"https://auth.giftgenius.com"
],
"scopes_supported": ["gifts.read", "gifts.write"]
}
Here:
- resource is the canonical ID of your resource; it must later be used as the audience or resource when issuing a token;
- authorization_servers is the list of Auth Servers from which ChatGPT can request a token;
- scopes_supported are the “permissions” your MCP server understands.
Authorization request: redirect to the Auth Server
Having received the metadata, the MCP Client goes to the Auth Server. It opens a browser tab:
GET https://auth.giftgenius.com/authorize
?response_type=code
&client_id=chatgpt-giftgenius
&redirect_uri=... (callback URL of the MCP Client)
&code_challenge=...
&code_challenge_method=S256
&scope=openid gifts.read
&resource=https://api.giftgenius.com
The user:
- sees a familiar login (for example, Keycloak or Auth0);
- enters username/password and completes 2FA;
- confirms that ChatGPT may read their gift lists (scope gifts.read).
Code → Token: exchanging the code for a token with PKCE
After a successful login, the Auth Server redirects the user back to the MCP Client with a code. The MCP Client:
- makes a POST to /token;
- passes the code and the code_verifier (which corresponds to the code_challenge from the previous step).
The Auth Server verifies PKCE: hashes the code_verifier and compares it to the original code_challenge. If everything checks out and the client is indeed the same one that initiated the flow, then it:
- issues a short-lived access_token (usually a JWT);
- includes in it:
- sub — the user ID in the Auth Server;
- aud or resource — your MCP server;
- scope — the permitted actions (gifts.read, openid, etc.).
Authenticated request: calling an MCP tool with a token
Now the MCP Client is ready to call your tool again, but this time with the header:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
The MCP server:
- verifies the token’s signature (via the Auth Server’s JWK) or via introspection;
- checks expiration (exp);
- checks aud / resource — that the token was indeed issued for https://api.giftgenius.com;
- inspects the scope and decides whether getUserGiftLists can be called.
After that it can safely query your database by some userId and return the user’s personal gift lists.
Note that up to this point we have only discussed the network flow: how the token is obtained and delivered to the MCP server. Next it is important to understand how from sub and other token claims you derive a concrete userId in your database — this is where the identity bridge comes into play.
4. Identity bridge: how a ChatGPT user becomes a userId in your DB
The most interesting piece of the architecture is the “identity bridge.” The MCP specification explicitly emphasizes: the MCP server does not know about ChatGPT users; it relies on the data in the token from the Auth Server.
The scheme looks roughly like this:
flowchart TD User[User in ChatGPT] -->|Login/SSO| Auth[Auth Server] Auth -->|JWT: sub, email, tenant| MCP[MCP Server] MCP -->|userId/tenantId| DB[(Your DB)]
Step by step it looks like this.
First, the Auth Server knows its users internally: it has entities like user, email, id, possibly tenant, roles. Upon a successful login it puts this information into the token (into claims):
{
"sub": "auth0|abc123",
"email": "user@example.com",
"given_name": "Alice",
"https://giftgenius.com/tenant": "tenant-42",
"scope": "openid gifts.read",
"aud": "https://api.giftgenius.com"
}
Second, the MCP Server, when validating the token, extracts these claims and decides who this is in its world. For example:
- if sub already exists in the User.authProviderId column — we take the associated userId;
- if not — we create a local record (on-the-fly provisioning) and link it.
A typical TypeScript snippet on the MCP server side (simplified, without signature verification) may look like this:
type TokenClaims = {
sub: string;
email?: string;
scope?: string;
};
async function mapClaimsToUserId(claims: TokenClaims): Promise<string> {
const user = await db.user.findUnique({ where: { authSub: claims.sub } });
if (user) return user.id;
const created = await db.user.create({
data: { authSub: claims.sub, email: claims.email ?? null }
});
return created.id;
}
Third, using its own userId the MCP server fetches whatever is needed: gift lists, order history, settings, plan.
Thus the Auth Server becomes the “bridge” between the external world (ChatGPT, Google, SSO) and your internal world (customer_id in the orders database).
5. Why you should separate the Auth Server and the MCP Server
You may be tempted to say: “Let my MCP server both show the login and issue the token itself.” Formally that is possible (you could embed a mini IdP inside it), but architecturally it is a bad idea. The reasons are quite down to earth.
First, security and scalability. An Auth Server is a heavy machine: 2FA, social login, password policies, account lockout, account recovery, login auditing, and possibly certifications. Rewriting this in every microservice (in every MCP server) is the road to hell and PCI-DSS. It is much easier to delegate this to Keycloak/Auth0 and simply verify their token.
Second, client interchangeability. Today you only have ChatGPT. Tomorrow you will add Claude Desktop, your own web frontend on Next.js, a mobile app. All of them can use the same Auth Server and the same OAuth 2.1 scheme, and your MCP server will simply continue to verify tokens. You won’t need to rewrite business logic for every new client.
Third, code cleanliness. Ideally, the MCP Server:
- can publish /.well-known/oauth-protected-resource;
- can verify the Bearer token and extract from it the userId, scopes, and tenant;
- implements the business tools (orders, gifts, profiles).
All login UI — forms, layout, social logins — lives in the Auth Server and does not clutter your backend.
6. What this looks like in our sample application GiftGenius
Let’s return to the application we are building throughout the course. Suppose we have:
- a ChatGPT App “GiftGenius” with a widget (Apps SDK) that can pick gifts;
- an MCP server on Node/Next.js that provides tools:
- searchGifts — anonymous, does not require login;
- getSavedGiftLists — personal, requires authentication;
- an Auth Server (later — Keycloak/Auth0), where each user has an account.
Anonymous vs. logged-in user scenario
If a user simply writes “pick a gift for my brother, 30 years old, loves board games,” our App can:
- call the anonymous tool searchGifts;
- return recommendations in the interface.
In this case:
- no token is required;
- the MCP server simply executes the request (for example, to your catalog or a third-party API).
As soon as the user says “save this to my lists” or “show my saved ideas,” the model decides to call the protected tool getSavedGiftLists. The server responds with 401 + WWW-Authenticate with resource_metadata. ChatGPT launches the OAuth wizard “Link GiftGenius account,” walks the user through login, and receives a token.
Then, on each protected call:
- the MCP Server now sees Authorization: Bearer ...;
- extracts the userId from the token;
- filters data by this userId.
Thanks to this we can:
- separate data of different users;
- safely show order history, favorites;
- enable commerce functions (later in the course).
Backend architecture: middleware + tool handlers
In practice, in Node/Next.js code this often looks like a chain: “authentication middleware → tool business handler.” In the lecture about implementing tool handlers, we already emphasized that they should receive a context: user_id, tokens, settings.
Here is a code fragment:
// auth-context.ts
export type AuthContext = {
userId: string | null; // null for anonymous calls
scopes: string[];
};
Middleware attached to all MCP endpoints:
// mcp-auth-middleware.ts
export async function buildAuthContext(req: Request): Promise<AuthContext> {
const header = req.headers.authorization || "";
const token = header.replace(/^Bearer\s+/i, "");
if (!token) return { userId: null, scopes: [] }; // anonymous user
const claims = await verifyAndDecodeToken(token); // token verification
const userId = await mapClaimsToUserId(claims);
const scopes = (claims.scope || "").split(" ");
return { userId, scopes };
}
The tool handler itself receives this context:
// tools/getSavedGiftLists.ts
export async function getSavedGiftLists(_args: {}, ctx: AuthContext) {
if (!ctx.userId) throw new Error("User must be authenticated");
return db.giftList.findMany({
where: { ownerId: ctx.userId }
});
}
The idea is that the tool handler knows nothing about OAuth or PKCE. It simply works with the “obvious” userId. All OAuth magic happens before it: in the MCP client and in the Auth middleware.
7. Visual diagrams: how Client, Server, and Auth live together
We have already broken down the flow step by step in section 3. Sometimes it is easier to draw once than explain seven times, so now we will show the same interactions as two diagrams.
Interaction skeleton (the triangle of trust)
flowchart TD U[User] -->|1. Login / Consent| A[MCP Auth Server] U -->|2. Chats| C["MCP Client (ChatGPT)"] C -->|3. OAuth Flow| A C -->|4. Bearer Token| S[MCP Server] S -->|5. Data| C
The diagram reads as follows.
First the user logs in via the Auth Server, which essentially confirms their identity and issues a token. The MCP Client orchestrates this process and then uses the token to talk to the MCP server. The MCP server never sees the username/password — it only sees the token and decides what is allowed.
Flow from request to response
sequenceDiagram participant User participant ChatGPT as MCP Client participant Auth as Auth Server participant MCP as MCP Server User->>ChatGPT: "Show my gift lists" ChatGPT->>MCP: callTool(getSavedGiftLists) (without token) MCP-->>ChatGPT: 401 + WWW-Authenticate (resource_metadata) ChatGPT->>Auth: /authorize + PKCE User->>Auth: Enters username/password, gives consent Auth-->>ChatGPT: redirect + code ChatGPT->>Auth: /token + code_verifier Auth-->>ChatGPT: access_token (JWT) ChatGPT->>MCP: callTool(getSavedGiftLists) + Authorization: Bearer ... MCP-->>ChatGPT: JSON with personal lists ChatGPT-->>User: Rendered list in the widget
This is the diagram you should be able to “explain with your eyes closed” by the end of the module.
8. A bit deeper: multiple resources, multiple clients, DCR
The beauty of this architecture is that it scales.
First, you may have multiple MCP servers (for example, one for gifts and another for orders) and a single Auth Server that issues tokens with different aud/resource. Each resource server must check that a token is actually intended for it; otherwise you get the classic “confused deputy” problem where a token for one service is accepted by another.
Second, you may have many clients:
- the ChatGPT App;
- your own frontend;
- a mobile app;
- a partner integration via an MCP Gateway.
They will all:
- read /.well-known/oauth-protected-resource;
- discover where the Auth Server is;
- go through the OAuth 2.1 flow;
- obtain tokens and call the MCP server.
Third, modern Auth Servers increasingly support Dynamic Client Registration (DCR) — the ability to register clients dynamically via API. The MCP specification implies just such a possibility: a client (ChatGPT/Jam) can automatically register itself on the Auth Server via its registration_endpoint.
For this module it is important to understand that:
- the MCP Client, MCP Server, and Auth Server communicate via standardised discovery documents and tokens;
- you do not need to hard-code all clients in your backend code;
- you can grow the ecosystem without breaking the existing authorization model.
9. Common pitfalls in understanding the MCP authorization architecture
Mistake #1: “The MCP server should log the user in itself.”
Sometimes developers try to embed a login form directly into the MCP server and then send username/password via tools. This breaks the very idea of OAuth. The MCP server must not see the password under any circumstances. Login and consent are the Auth Server’s responsibility. The MCP server works only with tokens and their claims.
Mistake #2: Confusing the MCP Client and MCP Server.
Sometimes ChatGPT is perceived as “part of my backend,” and developers try, for example, to store secrets in it or expect it to check access rights on its own. In reality the MCP Client merely initiates OAuth and attaches tokens. Token and permission verification is the MCP server’s job, not ChatGPT’s.
Mistake #3: “An API key in .env instead of OAuth.”
A classic anti-pattern: create a single big SERVICE_API_KEY, put it into the .env of the MCP server, and call it a day. In that setup there is no per-user permission separation, you cannot safely show personal data or make purchases, everything is done “on behalf of the service” rather than the user. This completely contradicts the goals of authorization in ChatGPT Apps.
Mistake #4: Ignoring audience and resource.
If an MCP server accepts any valid JWT with a matching signature and does not check aud/resource, then any token issued for another service by the same Auth Server can be used to call your tools. That is a direct violation of the OAuth security model. The server must verify that the token is issued specifically for its resource.
Mistake #5: Mixing auth logic and business logic.
Sometimes tool handlers start to pull in all token parsing, signature verification, JWK handling, etc. As a result the code becomes brittle and hard to maintain. It is much better to separate the layer “token verification, mapping to userId” (middleware) from the “actual tool logic” layer, which receives a clean AuthContext.
Mistake #6: Expecting ChatGPT to “do everything by itself” without .well-known.
Without the proper /.well-known/oauth-protected-resource endpoint, the MCP client simply does not know where your Auth Server is and which scopes are needed. The result — the chat silently “cannot log in,” while the developer stares at empty logs. The right approach: the MCP server clearly declares its authorization requirements via .well-known, the client reads them and builds the flow.
Mistake #7: Forgetting the user in business logic.
Sometimes, even after setting up OAuth and mapping the token to a userId correctly, developers do not use it in database queries — for example, they forget to filter by ownerId = userId. Then any authorized user may see someone else’s data. Having a token is only the first step; the second step is always correct use of the userId and scope in business code.
GO TO FULL VERSION