1. Why authentication is needed in a ChatGPT App in the first place
Let’s start with the main point: a user in ChatGPT ≠ a user in your service.
ChatGPT has its own user account. Your service has its own userId, tenantId, roles, billing, orders. There is no magical link between them by default. If you just spun up an MCP server and described a few tools, ChatGPT will call them as an abstract client.
Recall our hypothetical GiftGenius app—a ChatGPT App that helps pick gifts and manage wishlists. What we want to be able to do:
- Show the user their saved gift lists.
- Allow marking gifts as “bought” or “received.”
- Show order history (especially if we later go into commerce/ACP).
Without authentication the MCP server does not know “who this is.” At most it sees some technical connection identifiers and an anonymous subject that OpenAI provides for identification and rate limits, but it explicitly warns that this must not be used for authorization.
Authentication vs authorization
It’s very helpful to immediately separate two concepts.
- Authentication (AuthN) answers the question: who is this?
- Authorization (AuthZ) answers: what is this “someone” allowed to do?
For a ChatGPT App, the flow looks roughly like this:
- First, via OAuth you confirm that the user is actually signed in to your Identity Provider (IdP) (for example, Keycloak/Auth0) and get a token with their identifier. This is authentication.
- Next, the MCP server reads the token, extracts sub, roles, and other claims, and decides whether this user may invoke a specific tool (list_orders, delete_profile, etc.). This is authorization.
At the code level, you can think of it like this (simplified):
// Data type the MCP server wants to know about the user
export interface AuthContext {
userId: string;
roles: string[];
}
// Example usage in a tool handler
async function listGiftLists(auth: AuthContext | null) {
if (!auth) {
throw new Error("User is not authenticated");
}
// Fetch only this user's lists from the DB
return db.giftLists.findMany({ where: { ownerId: auth.userId } });
}
Without userId and roles, you simply won’t be able to write correct business logic. Everything will turn into “one big shared account for everyone.”
2. Why “an API key in .env” is not a solution
As developers, we have a natural reflex: “I’ll make an API key, put it in .env, and everything will work.” And indeed, for internal service-to-service integrations, API keys are a normal tool. But as soon as real users and a ChatGPT App are in play, the “one key for everyone” approach breaks down.
Let’s look at typical code from early modules where we just called our backend from MCP:
// mcp/backendClient.ts
export const backendClient = new BackendClient({
baseUrl: process.env.BACKEND_URL!,
apiKey: process.env.BACKEND_API_KEY!, // single key for all of ChatGPT
});
From the backend’s point of view all requests now look the same: “this is the ChatGPT integration.” No difference between Masha and Pasha. Which means:
- You can’t show a “personal dashboard” — the server doesn’t know whose it is.
- You can’t separate permissions: “this user can only read, and this one can also purchase.”
- You can’t link orders to a person in your primary system.
In the MCP world this is also unsafe. The specification recommends using HTTP authentication (Bearer, API keys, etc.) via Streamable HTTP, but emphasizes that proper user access to protected resources is better built with OAuth and tokens, not a single service key.
Plus, from OpenAI policy’s perspective, a good app should request only the data it really needs and give the user control over what they share with the App. This fits perfectly with the OAuth scopes model but does not mix at all with the “one super-key that can do everything” approach.
Why a service key is bad in the context of ChatGPT
A service API key represents the identity of a service, not a user. It can be used to sign calls from your MCP server to internal services or to external APIs (like the OpenAI API), but it can’t say: “Here, this is Vasya, show him his order history.”
The simplest anti-example:
// Bad option: a "fake-out" for the user
async function getMyOrdersFromBackend() {
// MCP server calls /orders/me on the backend
const res = await fetch(`${BACKEND_URL}/orders/me`, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
},
});
// The backend thinks that "me" is an integration service, not a human
return res.json();
}
Even if you try to artificially embed some anonymous userId into the request body, it’s still a homegrown workaround. You will still need:
- A reliable way to prove to the backend that “this really is Vasya, not someone else.”
- A way to limit a specific user’s permissions.
- A mechanism to revoke access for a specific user, not for everyone at once.
This is where OAuth comes in.
3. Mini glossary: what we actually want from a sign-in system
Before jumping into OAuth history, let’s simply formulate the requirements for a “normal” authentication system for a ChatGPT App.
We need a way in which:
- Our external IdP (Keycloak, Auth0, Hydra+Kratos, etc.) knows the real user: login, email, userId, possibly tenant.
- This IdP issues a short-lived token that ChatGPT can safely pass to the MCP server in the HTTP header Authorization: Bearer <token>.
- The MCP server reads the token and verifies the signature, issuer, audience, expiration, and scopes, extracts sub (the user identifier), and based on this maps the user to your domain entities (accountId, tenantId).
- These same scopes allow fine-grained permission control: one token grants only read:gifts, another also grants write:gifts or checkout.
- If the token is missing or lacks the required scopes, the server can return an error with _meta["mcp/www_authenticate"], so that ChatGPT shows the user an authorization UI and/or re-acquires a token.
In short, we need a standard, time-tested protocol that can do all of this. Spoiler: it’s OAuth 2.1 (and its older/younger siblings).
4. A brief evolution of OAuth: from dinosaurs to PKCE
Now let’s carefully walk through the evolution of OAuth, without diving deep into RFCs, but with an understanding of why we care about modern patterns in particular.
OAuth 1.0/1.0a: crypto fitness
Historically, OAuth 1.0 came first. It allowed websites to give other services access to their resources without sharing the user’s password (already a good thing). But:
- Request signatures were complex: HMAC-signing nearly every request, base strings whirling around, parameter normalization.
- Every request had to be signed, you had to store a consumer secret, and correctly form the signature.
Most modern developers are not thrilled to manually repeat all these tribal dances.
The 1.0a spec fixed some vulnerabilities, but the overall heaviness remained.
OAuth 2.0: a framework, not a single protocol
OAuth 2.0 greatly simplified life: instead of one strictly prescribed scheme, there was a set of flows (authorization code, implicit, resource owner password, client credentials, etc.). This brought flexibility but also a zoo of implementations.
Pros:
- Easier to integrate SPA, mobile, and server apps.
- Clear separation of roles: Resource Owner, Client, Resource Server, Authorization Server.
Cons:
- In the real world, many dangerous “shortcuts” appeared. The implicit flow (which handed a token directly to the browser without a server-side code exchange) proved unsafe.
- The password grant flow (where the client just sends the user’s login/password in exchange for a token) contradicts OAuth’s very philosophy—and became an anti-pattern.
The specification itself left too many “choose your own adventure” options, so a lot of recommendations and best practices lived in separate RFCs and blogs.
OAuth 2.1: consolidate and clean up
OAuth 2.1 is an attempt to document best practices that had already formed in the community by then:
- Focus almost entirely on the Authorization Code Flow as the primary working option.
- PKCE (Proof Key for Code Exchange) is mandatory for public clients— those that can’t store a secret (for example, mobile apps, SPAs, and… ChatGPT/MCP clients).
- Deprecated and unsafe flows like implicit and password grant are simply excluded from the spec.
- Recommendations for short access token lifetimes and for using refresh tokens for long-lived sessions.
Why does this matter for you? Because the ecosystem around MCP and ChatGPT clearly orients itself around these best practices: the Apps SDK and the MCP Authorization spec explicitly require authorization code + PKCE, short-lived tokens, and proper scopes.
5. Why in a ChatGPT App world we think in terms of OAuth 2.1 + PKCE
Now that we have historical context, let’s view this through the lens of ChatGPT and MCP.
ChatGPT as a public client
ChatGPT (and clients like MCP Jam) relative to your Auth Server are typical public clients:
- It does not—and cannot—reliably store a client_secret.
- It runs in OpenAI’s infrastructure, which you do not control.
Therefore, the only reasonable choice is Authorization Code Flow + PKCE, where security relies not on a client secret but on verifying the code challenge and code verifier.
The official Apps SDK documentation explicitly says that ChatGPT, acting as an MCP client, performs the flow with Authorization Code + PKCE (S256) and will refuse to complete authorization if your Authorization Server does not advertise PKCE support in metadata: code_challenge_methods_supported: ["S256"].
What the flow looks like from an MCP perspective
Very roughly, but helpful to picture it like this (sequence for a protected resource):
sequenceDiagram
participant U as User
participant C as ChatGPT (MCP Client)
participant AS as Auth Server
participant RS as MCP Server (Resource)
U->>C: "Show my orders"
C->>RS: call_tool(list_orders) without a token
RS-->>C: Error + _meta["mcp/www_authenticate"]
C->>AS: Opens login/consent (Authorization Code + PKCE)
U->>AS: Signs in and grants consent (scopes)
AS-->>C: Authorization Code
C->>AS: Code exchange for Access Token (+PKCE verification)
AS-->>C: Access Token (Bearer)
C->>RS: call_tool(list_orders) with Authorization: Bearer <token>
RS->>RS: Verify signature, issuer, audience, scopes
RS-->>C: User's order list
C-->>U: Displays data
The server uses:
- Protected resource metadata (/.well-known/oauth-protected-resource) — there it advertises itself as a resource and indicates which Authorization Server serves this resource.
- The token that arrives in the Authorization: Bearer <token> header, which it either verifies as a JWT via JWK or introspects via the Auth Server.
- If the token does not fit by audience or scopes, the server can reject the request and again return a WWW-Authenticate challenge in _meta["mcp/www_authenticate"], so that ChatGPT re-runs authorization with the required parameters.
From your code’s point of view, all of this is humane: you receive an already verified AuthContext and work with it.
Mini-example: how an MCP tool distinguishes an anonymous and an authenticated user
Without a specific OAuth SDK for now—just the concept:
import type { McpToolHandler } from "./types";
export const listOrders: McpToolHandler = async (_args, context) => {
const auth = context.auth; // suppose we put the token verification result here
if (!auth) {
return {
content: [{ type: "text", text: "You need to sign in to view orders." }],
_meta: {
// Challenge for ChatGPT: start the OAuth flow
"mcp/www_authenticate": [
'Bearer resource_metadata="https://mcp.giftgenius.app/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="Login required to view orders"'
]
},
isError: true
};
}
const orders = await db.orders.findMany({ where: { userId: auth.userId } });
return {
content: [{ type: "text", text: `Orders found: ${orders.length}` }],
structuredContent: orders
};
};
This exact _meta["mcp/www_authenticate"] hint is described in the official Apps SDK documentation as a trigger for the OAuth UI on the ChatGPT side.
6. What “short-lived token, minimal scopes” means in practice
Several more important principles follow from the specs and guides, which you should keep in mind now, before the next lecture on configuring an IdP.
Short access token lifetime
An access token should live briefly. Why?
- If it leaks, an attacker is still time-limited.
- You can safely change a user’s permissions, and after a short time the token will “expire” and a new one will be requested.
Typically this is minutes or tens of minutes. In return you use refresh tokens and/or repeated authorizations, but in the context of ChatGPT the client side handles most of the routine.
Scopes as a way to limit permissions
Scopes are strings like gifts.read, gifts.write, orders.read, orders.checkout. They indicate exactly what the user is allowed to do for this resource.
For a ChatGPT App this is especially important:
- You can issue a token with only gifts.read when the user is just browsing wishlists.
- And for ACP/Instant Checkout operations it makes sense to request a stricter set of rights—for example, orders.checkout, and explicitly highlight this to the user.
In the MCP tool description you can already declare securitySchemes with specific scopes for tools, so that ChatGPT knows which permissions are needed to call a particular tool.
Audience: the token must be “for this” MCP resource
Another important detail is aud (audience). The MCP server must verify that the token was indeed issued for it, not for some neighboring service.
The Apps SDK documentation explicitly says that ChatGPT will pass the resource parameter and expects the Authorization Server to reflect it in the token (usually in aud), and the MCP server should verify this field.
There is a high chance that during the review of your application, they will pass a forged auth_token to it and check for holes in your security implementation. So do it right from the start.
7. How this maps to our GiftGenius app
Let’s focus again on our training app. Right now we have roughly this picture:
- There is an MCP tool get_gift_ideas that suggests gift ideas based on the recipient’s description and budget. This can work anonymously.
- There is an MCP tool save_gift_list that saves a list to the DB. We want it to be tied to a specific user.
- There is an MCP tool list_saved_lists that shows all lists saved by the user. This definitely requires authentication.
A widget shows beautiful gift cards, lets you click “save” and “mark as purchased” — all of this is essentially a front end to protected MCP tools.
At the type level it might look like this:
// Typing for the tool invocation context (simplified)
interface ToolContext {
auth: AuthContext | null;
}
// Example of a protected tool
async function listSavedGiftLists(_input: {}, context: ToolContext) {
if (!context.auth) {
// We'll use the same mcp/www_authenticate trick as above
throw new Error("Authentication required");
}
return db.giftLists.findMany({
where: { ownerId: context.auth.userId }
});
}
And as soon as you write such functions, it becomes clear: “just an API key in .env” won’t help. You need a full-fledged AuthContext built from a validated OAuth token.
Which parts of the app can work anonymously, and which cannot
A good exercise before configuring OAuth is to go through the functionality and honestly split it into two categories.
For example, in GiftGenius:
Anonymous:
- Generating gift ideas based on a description.
- Showing examples and a demo mode with mock data.
Authenticated only:
- Viewing and editing personal wishlists.
- Order history.
- Any payment operations, Instant Checkout, binding to ACP.
In the next lectures, we will configure an Auth Server (for example, Keycloak or a Hydra+Kratos combo) and the MCP server so that tokens for these actions have the required scopes, and MCP tools can correctly deny and ask ChatGPT to re-authorize.
8. Common mistakes when understanding authentication in a ChatGPT App
Error #1: “ChatGPT already knows the user, why do I need my own login?”
Many think: “ChatGPT has a user account, why not just use it as userId?” But ChatGPT does not reveal the user’s real identity to you and does not give access to its accounts. In MCP metadata you might only see an anonymous _meta["openai/subject"], which is intended for rate limits and session identification, and it is explicitly stated that it must not be used for authorization or linking to real accounts.
Error #2: “One API key for everyone is fine, it’s just an ‘integration’”
The approach “we hardcoded an API key to our backend in the MCP server and we’re happy” only works for scenarios where all ChatGPT users share the same account in your service. As soon as personal data, commerce, and ACLs appear, you run into the inability to distinguish users and manage their permissions. An API key is the identity of a service, not a user.
Error #3: “Let’s implement password grant, it’s the easiest”
The habit of sending the user’s login/password to your backend and exchanging it for a token (Resource Owner Password Credentials Grant) is an outdated and unsafe pattern from the early days of OAuth 2.0. In modern recommendations and in the context of OAuth 2.1 it is considered an anti-pattern. Public clients like ChatGPT should not see your users’ passwords at all—that’s what Authorization Code + PKCE exists for.
Error #4: “PKCE is unnecessary complexity, let’s do without it”
PKCE (especially S256) is not trendy marketing, but a mandatory protection mechanism for the Authorization Code Flow for public clients. Without PKCE, a stolen authorization code can be reused. The MCP Authorization spec and the Apps SDK explicitly state that ChatGPT requires PKCE support to be declared in the Authorization Server metadata and uses exactly this mechanism. If you disable it, the flow simply won’t work.
Error #5: “Let’s request all possible scopes—just in case”
Sometimes you want to mint a token with permissions to “open and format C: drive.” But that violates the principle of least privilege (PoLP) and conflicts with the policies of both OpenAI and most IdPs. It’s better to carefully think through which scopes your ChatGPT App really needs: some for reading, others for writing, separate ones for commerce. This not only improves security but also affects the consent UX: the user sees a clear and limited set of permissions rather than a scary list of twenty confusing strings.
Error #6: “The MCP server will store logins/passwords and render the login UI itself”
The MCP server is a Resource Server, not an Auth Server. It should be able to verify tokens, advertise its .well-known metadata, and return WWW-Authenticate challenges, but not handle login and store passwords. For login/consent, use a specialized Authorization Server (Keycloak, Hydra, Auth0, etc.), as we will see in the following lectures.
GO TO FULL VERSION