
Securing an MCP Server with OAuth 2.1 and Keycloak: How Engram Moved from Bearer Tokens to a Real Resource Server
Most MCP servers ship with a static bearer token. It’s one line in .mcp.json, it works on day one, and it’s the wrong answer the moment your server is anything other than a personal dev tool.
Engram, the semantic memory layer behind my AI-native development workflow, started there too. A shared bearer header in .mcp.json, validated by a hand-rolled middleware. Fine for a single developer running local tools. Not fine for a multi-client production server with Claude Desktop, Claude Code, Codex, and eventually third-party tenants all connecting over the public internet.
That shift — from personal tool to shared AI infrastructure — is the same evolution I wrote about in the production follow-up to the semantic knowledge system.
This is a walkthrough of how I replaced it with a proper MCP server OAuth 2.1 resource-server implementation — and what the MCP spec actually requires you to do instead.
Table of Contents
The problem with static bearer tokens
A shared bearer token is a shared secret. Rotate it and you update every client config simultaneously. Leak it and you have no scoping, no audience restriction, no expiry that matters in practice. There’s also no discovery: a client that hits your server for the first time has no way to know what grant type to use, where your authorization server lives, or what scopes it needs.
The MCP specification has an answer to this. It’s called OAuth 2.1, and it’s not optional for production deployments.
What the MCP spec actually says
MCP’s auth spec mandates that a protected HTTP server implement RFC 9728 — OAuth 2.0 Protected Resource Metadata. In plain terms: when an unauthenticated client hits your MCP endpoint, your server must return a 401 with a WWW-Authenticate header pointing the client at a discovery document. That document tells the client where the authorization server is, what scopes are supported, and how to authenticate.
GET /api/v1/mcp
Authorization: (none)
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="engram",
resource_metadata="https://engram.network-ideas.net/.well-known/oauth-protected-resource"Code language: Markdown (markdown)
The client fetches the discovery document, navigates to Keycloak, obtains a token, and retries — all without any static configuration beyond the MCP server URL. This is the handshake Claude Code and Codex both implement natively when you remove the bearer header from their config files.
The implementation
Engram runs on ASP.NET Core. The auth layer has three moving parts.
1. JWT bearer validation
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = auth.Authority; // Keycloak realm URL
options.MapInboundClaims = false; // preserve raw JWT claim names
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = auth.Authority,
ValidateAudience = true,
ValidAudience = auth.Audience, // https://engram.network-ideas.net
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
NameClaimType = "sub"
};
options.Events = new JwtBearerEvents
{
OnChallenge = context =>
{
context.HandleResponse();
context.Response.StatusCode = 401;
context.Response.Headers.Append(
"WWW-Authenticate",
ProtectedResourceMetadata.ChallengeHeader(
auth.Audience, context.Error, context.ErrorDescription));
return Task.CompletedTask;
}
};
});Code language: C# (cs)
MapInboundClaims = false is important. ASP.NET’s default claim mapping rewrites sub to a long Microsoft URI and scope to a different name. Since you’re validating raw Keycloak claims, you want them untouched.
2. The RFC 9728 discovery endpoint
app.MapGet(ProtectedResourceMetadata.Path, (EngramAuthOptions auth) =>
Results.Ok(ProtectedResourceMetadata.Build(auth)))
.AllowAnonymous();Code language: C# (cs)
This serves the JSON document that tells clients where to go. It must be unauthenticated — a client that doesn’t yet have a token needs to be able to read it. Same for /health.
The document itself:
{
"resource": "https://engram.network-ideas.net",
"authorization_servers": ["https://keycloak.network-ideas.net/realms/engram"],
"scopes_supported": ["context.read", "context.ingest"],
"bearer_methods_supported": ["header"]
}Code language: JSON / JSON with Comments (json)
3. Scope-separated authorization
Engram has two distinct surfaces: MCP read tools (context.read) and the ingest write API (context.ingest). A token carrying only context.read is fully authenticated but returns 403 Forbidden on write endpoints. This lets you issue read-only tokens to AI agents and scoped write credentials to the ingest client — different trust levels, different grants, same server.
builder.Services.AddAuthorization(options =>
options.AddEngramScopePolicies(auth));Code language: C# (cs)
The Keycloak gotcha nobody documents
Here’s the thing that will cost you an hour if you don’t know it upfront.
The JWT spec (RFC 7519) and the OAuth resource indicator spec (RFC 8707) say the token’s aud claim should contain your resource identifier. Keycloak agrees conceptually, but does not implement RFC 8707’s resource parameter. It uses its own proprietary mechanism: an audience protocol mapper configured on the client.
Without the mapper, Keycloak issues tokens with aud set to the client ID — not your server’s resource identifier. Your ValidAudience check fails on every token, and the error is not obvious.
The fix is straightforward once you know: in Keycloak, on your engram-mcp client, add a mapper of type “Audience”, set the included audience to https://engram.network-ideas.net, and ensure it applies to access tokens. After that, issued tokens carry the right aud value and validation passes.
Client config after the migration
Before:
{
"mcpServers": {
"engram": {
"type": "http",
"url": "https://engram.network-ideas.net/api/v1/mcp",
"headers": { "Authorization": "Bearer <static-token>" }
}
}
}Code language: JSON / JSON with Comments (json)
After:
{
"mcpServers": {
"engram": {
"type": "http",
"url": "https://engram.network-ideas.net/api/v1/mcp",
"oauth": { "clientId": "engram-mcp", "callbackPort": 51990 }
}
}
}Code language: JSON / JSON with Comments (json)
No secret in the config file. The OAuth flow handles everything. Claude Code reads the WWW-Authenticate header, fetches the discovery document, redirects to Keycloak, and completes the PKCE flow on the callback port. The bearer header is gone from source control permanently.
What this buys you
Beyond the obvious security improvement, the RFC 9728 discovery pattern is what makes Engram viable as product infrastructure rather than a private developer tool. That matters because Engram is not only a search layer; it is the memory substrate behind larger AI-native systems like Continuum. Each instance has its own resource identifier, its own realm, its own audience. The client config is just a URL. The rest is negotiated at runtime.
If you’re building an MCP server intended to be more than a local dev convenience, this is the architecture to start with — not migrate to.
More coding articles