CodeGym /Courses /ChatGPT Apps /Setting Up an MCP Auth Server: the Keycloak example

Setting Up an MCP Auth Server: the Keycloak example

ChatGPT Apps
Level 10 , Lesson 2
Available

1. What an auth server is in practice and why we choose Keycloak

Let’s start with a quick reminder: an auth server (IdP) is a service that:

  • shows the user a login/registration and consent screen;
  • issues OAuth/OIDC tokens (access_token, id_token, refresh_token);
  • publishes a discovery document and JWKS keys so resource servers can verify those tokens.

In our stack:

  • ChatGPT / MCP Jam acts as an OAuth client (public client);
  • your MCP server is the Resource Server;
  • Keycloak is the Auth Server.

Why Keycloak is convenient for the course and real life:

  • it’s open source and easy to run locally/in Docker;
  • it has a fairly transparent model of entities: realm, clients, users, roles;
  • in essence, almost any configuration you master in Keycloak transfers almost 1:1 to Auth0/Okta/Cognito: the same ideas — client, scopes, redirect URIs, PKCE.

Important idea: we are setting up not “Keycloak for the whole project,” but a specific Realm for our ChatGPT app. It’s a sort of authentication “sandbox” specifically for MCP clients.

In short, by the end of the lecture you will have:

  • your own realm in Keycloak for the ChatGPT application;
  • a configured public client with Authorization Code + PKCE;
  • a minimal set of scopes and claims;
  • an understanding of how the token lives inside your Node MCP server.

2. Keycloak fundamentals through the MCP lens

So you don’t get lost in the admin UI later, let’s sort the entities out.

Realm: a space for settings and users

A realm in Keycloak is an isolated space with its own users, clients, and policies. A handy analogy is “a rented office in a business center”: each tenant has their own suite, their own employee list, and their own entry rules.

For the course and for your first real app, it makes sense to create a separate realm, for example giftgenius-mcp or mcp-course. This lets you:

  • avoid touching the master realm so you don’t accidentally break the admin UI;
  • reuse settings and users across environments (dev / staging / prod) via realm export/import.

Client: a record about an application (ChatGPT / MCP Jam)

A client in Keycloak is not a “user,” but an application that goes to the auth server for tokens. In our case it is not your Next.js backend, but the MCP client itself: ChatGPT, MCP Jam, possibly your separate widget if you implement a manual OAuth flow in the UI.

Key client fields:

  • client_id — a string identifier;
  • type (public / confidential / bearer-only);
  • enabled OAuth flows (Standard Flow, Client Credentials, etc.);
  • the list of allowed redirect URIs;
  • the list of scopes and protocol mappers (claims in the token).

For ChatGPT/MCP Jam we need a public client because:

  • ChatGPT as a client cannot safely store a client_secret;
  • MCP Jam as a desktop/browser tool also runs in an untrusted environment.

User: a real person

A user is a real human: they have a username, password, email, attributes, groups, and roles. When someone logs in via Keycloak, their sub and other data end up in the token, which you then validate on the MCP server and map to your own accountId / tenantId.

For our demo, it’s sufficient to have:

  • one or two test users (for example, alice@example.com, bob@example.com);
  • optionally a couple of attributes like tenant or plan if you want to show how token claims affect tool behavior.

3. Choosing the client type: public, PKCE, and why there’s no secret

Now to the point: how exactly to configure a client in Keycloak for ChatGPT/MCP.

Public vs confidential: why not client_secret

In a classic web app you build a backend, put a client_secret there, and the server goes to the IdP for tokens. That is a confidential client: it can store a secret.

In the ChatGPT world it’s the opposite:

  • the OAuth client is the ChatGPT platform itself or a tool like MCP Jam;
  • you don’t control its code and environment;
  • any client_secret you give ChatGPT should be considered compromised immediately.

Therefore, ChatGPT/Jam work as public clients, i.e., without a client_secret, and compensate for that with PKCE — Proof Key for Code Exchange.

PKCE in plain English

PKCE is a one-time “secret per session.” Its goal is to prevent someone from simply intercepting an authorization code and exchanging it for a token from somewhere else. The flow looks like this:

  1. The client generates a random string code_verifier.
  2. It hashes it (usually SHA-256) and gets a code_challenge.
  3. When redirecting to /authorize, it sends the code_challenge and code_challenge_method=S256.
  4. After login, the user is returned with a code to the redirect URI.
  5. The client makes a POST to /token, providing the code and the original code_verifier.
  6. Keycloak hashes the verifier, compares it to the challenge, and if they match, issues a token.

What matters for us: ChatGPT/MCP Jam handle all of this for us. We only need to enable Authorization Code + PKCE (S256) for the client in Keycloak and not require a client_secret.

4. Step-by-step Keycloak setup for the MCP scenario

So we’ve decided: for ChatGPT/MCP Jam we need a public client with Authorization Code Flow and PKCE (S256), without a client_secret. Now let’s see what this configuration looks like in Keycloak.

Assume you already have a working Keycloak (Docker container, local install — doesn’t matter). Right now, we care about the logic of the settings, not which buttons to click.

Create a new realm for the app

Create a realm giftgenius-mcp: it’s a separate area that will include:

  • users specifically for the ChatGPT application;
  • clients through which ChatGPT/MCP Jam will go through OAuth;
  • its own password and token policies.

Practical tip: do not mix the realm where you authorize admin staff with the realm for ChatGPT clients. It’s both safer and logically simpler.

Add a test user

Create a user, say alice:

  • username: alice;
  • email: alice@example.com;
  • set a password (for simplicity — without strict policies);
  • optionally add an attribute tenantId=demo-tenant or a role ROLE_PREMIUM.

Later, in the MCP server, you’ll decode the token, extract sub, email, tenantId, and link them to your own user model.

Create a public client for MCP Jam / ChatGPT

Now for the most interesting part — the client.

Conceptually, the parameters should look like this:

  • Client ID: giftgenius-mcp-client (name as you prefer);
  • Type: public / Client Authentication off;
  • Standard Flow (Authorization Code) enabled;
  • PKCE enabled with method S256;
  • redirect URIs configured;
  • required scopes configured (openid + your custom one, for example, mcp:tools).

Enable Standard Flow and PKCE

Conceptually:

  • enable Authorization Code Flow (often called “Standard Flow Enabled”);
  • in the PKCE section, set pkceRequired=true and, most of the time, explicitly set code_challenge_method=S256.

Why S256: in modern OAuth 2.1 documentation and OpenAI/Model Context Protocol guidance, S256 is the supported secure method; plain PKCE is considered unsafe.

Redirect URI — the most brittle piece

Redirect URIs must match character for character with what the client will use. Otherwise you’ll get an invalid_redirect_uri error during authorization.

In our course there are two typical clients:

  1. MCP Jam/Inspector for debugging. They typically run on http://localhost:PORT/.... For a local scenario it’s logical to allow a redirect like:
    • http://localhost:5173/* or the specific path Jam uses.
  2. ChatGPT / Apps SDK in production. Here the redirect URI is determined by the platform itself. In a real integration you will check the current OpenAI documentation and enter the URL ChatGPT uses as the callback.

The key point for this lecture: ChatGPT cannot just pick any redirect; it must exactly match what’s recorded in the auth server. Therefore:

  • never use * and “any URL will do”;
  • for local development, wildcards within localhost can be acceptable, but not for production.

Scopes: minimal, yet sufficient

Scopes are a mini-list of permissions requested by the client.

For our MCP scenario you most often need:

  • openid — to enable OpenID Connect and receive an id_token with a sub field, sometimes email;
  • a custom scope, for example mcp:tools, to denote “access to MCP tools is allowed.”

In Keycloak you can do this via Client Scopes:

  • keep openid;
  • disable by default extra scopes like profile and email if you don’t need them;
  • add a new scope mcp:tools, which you will then use to restrict access to tools on the Resource Server.

This matters for two reasons:

  1. Without openid you won’t receive an id_token and some standard OIDC fields.
  2. Without a separate custom scope you can’t clearly state on the MCP server side: “this token can be used to call my tools.”

5. Token settings: lifetime, signature, and claims

Now let’s see what tokens Keycloak will issue and how to tailor them for the MCP scenario.

Access token lifetime

In the realm settings, Keycloak has a Tokens section where you can set:

  • Access Token Lifespan;
  • Refresh Token Lifespan and other timeouts.

For a ChatGPT app, short-lived access tokens are important:

  • a few minutes or hours is a reasonable value;
  • if a token expires, the MCP server responds with 401, ChatGPT restarts the OAuth flow, and the user logs in again if necessary.

The idea is the same as in OpenAI’s Apps SDK documentation: short TTL + token refresh and the ability to “log a user out” quickly by revoking the token on the IdP side.

Refresh tokens for the ChatGPT client are generally either less critical or issued with a short lifetime so you don’t keep eternal sessions.

Which claims we want to see in the token

At a minimum we need:

  • sub — a unique user identifier in Keycloak;
  • iss — who issued the token (issuer);
  • aud — which resource the token is for (used later in the MCP server);
  • exp — expiration time;
  • scope — the list of scopes.

Additionally, often useful:

  • email — if you want to see the user’s address;
  • tenantId or a similar claim — for multi-tenant scenarios;
  • roles — for additional authorization.

In Keycloak this is configured via Protocol Mappers:

  • standard mappers for email, preferred_username, etc.;
  • custom mappers for user attributes (user.attributeclaim.name).

Example: a mapper that adds email as a claim in the token by setting user.attribute=email, claim.name=email.

On the MCP server side you can take these claims from the parsed JWT and:

  • link sub to your accountId;
  • use tenantId to fetch only the data belonging to that tenant;
  • use roles to implement finer-grained permissions.

Token signature and JWKS

By default, Keycloak signs access/id tokens with an asymmetric algorithm (typically RS256) and publishes public keys via the JWKS endpoint from the OpenID Discovery document.

For us this is important because the MCP server can:

  • take the issuer from the token;
  • use /.well-known/openid-configuration to find the JWKS endpoint;
  • fetch the public key and verify the token signature locally.

We will cover this in more detail in the lecture on making the MCP server a protected resource, but it’s already useful to understand why Keycloak exposes these metadata.

6. Dynamic Client Registration (DCR): when it’s needed

This section is more advanced. So far, we’ve configured the client “by hand” in the admin UI, and that’s more than enough to launch the app. But OAuth allows clients to register dynamically via a dedicated endpoint.

In the context of ChatGPT and MCP, OpenAI explicitly says the platform can use Dynamic Client Registration. That is, ChatGPT registers itself with the auth server “on the fly” via the registration_endpoint from the discovery document.

In Keycloak it looks like this:

  • enable DCR at the realm level;
  • configure a policy: who can register new clients and with which grant types/scopes.

An example JSON for registering a public client with Authorization Code + PKCE and scope openid mcp:tools might look like this:

{
  "clientName": "My ChatGPT App",
  "redirectUris": ["https://jam.proxy.mcpapps.com/callback"],
  "grantTypes": ["authorization_code"],
  "responseTypes": ["code"],
  "scope": "openid mcp:tools",
  "tokenEndpointAuthMethod": "none"
}

Where tokenEndpointAuthMethod: "none" means a public client without a client_secret.

For the course it’s enough to know that:

  • DCR is useful if there are many clients or they are short-lived;
  • ChatGPT can potentially register itself in your IdP;
  • but at first you can make do with a static client created in the UI.

7. How this ties into our training app

Recall that we have a training MCP server (for example, GiftGenius) that can:

  • provide a list of possible gifts;
  • store some user wish lists;
  • later — access the commerce part, place orders, etc.

While the MCP server is open, it doesn’t know who is calling it:

  • a request from ChatGPT might be logically “from Alice” or “from Bob,” but the MCP server doesn’t distinguish this;
  • you cannot show private gift history;
  • you cannot confidently charge the right account.

After configuring Keycloak as the auth server, the situation changes:

  1. ChatGPT understands from the .well-known of your MCP resource that it is protected and requires a token.
  2. ChatGPT sends the user to Keycloak via the Authorization Code + PKCE flow.
  3. The user logs in (our alice).
  4. ChatGPT receives an access token that includes sub, email, mcp:tools, and other claims.
  5. ChatGPT calls the GiftGenius tool with Authorization: Bearer <token>.
  6. The MCP server validates the token and understands: “Got it, this is Alice with sub=... and tenantId=demo-tenant,” and responds accordingly.

This linkage will be completed in the next lecture, where we’ll make the MCP server a “real” resource server: implement a metadata endpoint, token validation, and user binding.

8. Small practical examples (our stack: TypeScript + Node)

Everything below is not the “only correct” way, but a reference for how this can look in a typical Node/TypeScript stack. If you’re currently focused on clicking through Keycloak, you can skim this section and return to it when you connect the MCP server.

Although Keycloak configuration is mostly done in the UI or via its Admin REST API, it’s useful to show a couple of code snippets around it to make it clear how you’ll use all this on the MCP server side.

Assume we already have a Node.js MCP server (TypeScript) based on the official SDK.

Auth configuration (issuer and audience)

Create a small module authConfig.ts:

// authConfig.ts
export const authConfig = {
  issuer: 'https://auth.my-company.com/realms/giftgenius-mcp',
  audience: 'https://mcp.my-company.com', // URL of your MCP server
  requiredScopes: ['mcp:tools'],          // the minimum we expect in the token
};

Here, issuer is the Keycloak realm URL, and audience is the resource identifier (we’ll also use it when setting up the token and MCP).

Basic JWT verification via JWKS

In real life you’ll likely use a library like jsonwebtoken + jwks-rsa or utilities from the MCP SDK. The simplest skeleton might look like this:

// verifyToken.ts
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import { authConfig } from './authConfig';

const client = jwksClient({
  jwksUri: `${authConfig.issuer}/protocol/openid-connect/certs`,
});

function getKey(header: any, callback: any) {
  client.getSigningKey(header.kid, (err, key) => {
    const signingKey = key?.getPublicKey();
    callback(err, signingKey);
  });
}

export function verifyAccessToken(token: string): Promise<any> {
  return new Promise((resolve, reject) => {
    jwt.verify(
      token,
      getKey,
      {
        audience: authConfig.audience,
        issuer: authConfig.issuer,
      },
      (err, decoded) => (err ? reject(err) : resolve(decoded)),
    );
  });
}

Of course, error handling and key caching should be more careful, but you get the idea: Keycloak publishes JWKS keys, we fetch them and verify the signature.

Scope check and extracting identity

In middleware for MCP tools you can do something like:

// authMiddleware.ts
import { verifyAccessToken } from './verifyToken';
import { authConfig } from './authConfig';

export async function requireAuth(bearerToken: string) {
  const token = bearerToken.replace(/^Bearer\s+/i, '');
  const decoded: any = await verifyAccessToken(token);

  const scopes = (decoded.scope as string).split(' ');
  const hasScope = authConfig.requiredScopes.every(s => scopes.includes(s));
  if (!hasScope) {
    throw new Error('Insufficient scope');
  }

  return {
    userId: decoded.sub,
    email: decoded.email,
    tenantId: decoded.tenantId,
  };
}

And then in the MCP tool handlers you’ll use userId and tenantId to load the user’s gift lists. We’ve already implemented the tools in previous modules; for now, it’s important to see how a Keycloak token turns into an identity your backend understands.

9. Common mistakes when configuring Keycloak as an MCP auth server

Mistake #1: Using a confidential client with a client_secret.
Sometimes, out of habit, people create a client of type confidential and try to put a client_secret into the MCP/ChatGPT config. In the ChatGPT App ecosystem this shouldn’t work and won’t be secure: ChatGPT is a public client; it cannot store a secret. The right path is public client + PKCE.

Mistake #2: Scopes that are too broad by default.
Leaving profile, email, and a bunch of other standard scopes enabled — and issuing such tokens to every chat — is not a great idea. Better to minimize: openid and a specific mcp:tools (or a couple of app-specific scopes) are enough for early versions. This reduces the risk of leaking extra data and makes behavior more predictable.

Mistake #3: Incorrect redirect URI.
A classic: Keycloak has http://localhost:5173/callback configured, but MCP Jam uses http://localhost:5173/. Or vice versa. As a result — invalid_redirect_uri and a frustrating debugging session. Always verify the exact redirect URI value in Jam/ChatGPT documentation and enter it character for character.

Mistake #4: PKCE is not enabled or enabled with the wrong method.
Some Keycloak versions require you to explicitly enable “PKCE required” and set the method to S256. If you don’t, ChatGPT/Jam (which expects PKCE) may get an invalid_request error complaining about code_challenge. Always check PKCE settings for public clients.

Mistake #5: Incorrect or missing claims in the token.
Sometimes the token lacks sub or email because the corresponding scope or protocol mapper isn’t configured. As a result, on the MCP server you see a token but can’t map it to a real user. Solution: ensure the required fields (at least sub, and preferably email/tenantId) are mapped into the access/id tokens.

Mistake #6: Access tokens with a TTL that’s too long.
From a security standpoint, issuing access tokens for a day/week is a bad idea. If a token leaks, an attacker gets long-term access to the MCP resource. It’s better to make access tokens short-lived (minutes or hours) and require re-authorization when needed.

Mistake #7: Realm confusion and using master.
Sometimes the first thing people do is create clients and users directly in the master realm. Then they attach a couple more projects there — and eventually it’s unclear what is where. It’s better to create a separate realm for a specific app/course right away. This will make life easier for you and your DevOps team.

1
Task
ChatGPT Apps, level 10, lesson 2
Locked
PKCE generator and authorization link for a public client
PKCE generator and authorization link for a public client
1
Task
ChatGPT Apps, level 10, lesson 2
Locked
OAuth Playground in Next.js (Authorization Code + PKCE + tokens/claims)
OAuth Playground in Next.js (Authorization Code + PKCE + tokens/claims)
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION