BitFictionBitFiction
← Journal
mcpoauthkeycloaksecuritydevops

Making MCP, OAuth and Keycloak Agree

September 12, 2026 · 12 min read

We build Staticbot, which moves apps off AI builders onto infrastructure their owners actually control. Increasingly, the people using it are not sitting in our dashboard at all — they are in ChatGPT, or Claude Code, or the builder's own chat. So the product needed to be reachable as a tool: the Model Context Protocol, with OAuth so an assistant acts as a specific person rather than as a shared key.

We already ran Keycloak for our own users. On paper this was a configuration exercise. It was not, and almost none of the difficulty was in the parts the specs talk about. What follows is the reasoning, not just the settings.

The shape that matters: two identities, never one

The instinct when an assistant calls your MCP server, which then calls your API, is to pass the user's token along. It is right there. It identifies the user. Forwarding it seems like the honest thing to do.

It is the one thing you must not do, and the MCP specification says so explicitly. The reason is older than MCP: it is the confused deputy. A token minted for your MCP server is scoped to your MCP server. If your API also accepts it, then anything that ever gets hold of that token can act as that user against an API that never authenticated them — and your MCP server has become a deputy that launders authority.

So there are two identities on every call, and they never merge:

  • The caller is the end user, proving who they are with a token whose audience is the MCP resource. That token is verified at the MCP server and stops there.
  • Upstream, the MCP server calls the API as itself — a service account — and asserts which user it is acting for in a header the API resolves server-side.

That second half carries a rule that is easy to get wrong: the organisation, the tenant, whatever your unit of isolation is, must be resolved from the asserted actor. Never read it off the request. A client that can name its own tenant is a cross-tenant hole with extra steps. We have a test whose entire job is to pin that, and the test name says so out loud.

Everything rests on the audience check

Everything above only works if the audience check is real. It is one line of configuration and it is the difference between a security boundary and a decoration.

The way to know yours works is not to read it. It is to take a valid, correctly signed, unexpired token issued by the same realm for a different audience — your internal API, say — and present it to the MCP endpoint. If it is rejected, the boundary exists. If it is accepted, you have two names for one trust domain and no boundary at all.

Run it against a live realm, deliberately, and record the result. It takes five minutes, and it is the only evidence that the rest of the design means anything.

A 401 has to say where to go

Here is a failure mode that produces no error anywhere and simply does not work: the client gets a 401 and has nowhere to go next.

An MCP client that has never met your server does not know which authorization server to use, what scopes exist, or what the resource identifier is. The protocol's answer is that your 401 carries a header pointing at a protected-resource metadata document, and that document names the authorization server, the resource, and the supported scopes.

Leave the hint off and the connect flow stalls before it starts, with a 401 that looks like a credentials problem and is actually a discovery problem. There is no log line for "the client gave up". Adding the header is trivial; knowing that it is what is missing is the hard part.

Dynamic client registration, and the newer thing that replaces it

The next question is how a client you have never met gets a client identifier at all. There are two answers now, and they behave differently enough that it is worth supporting both.

The classic one is RFC 7591 Dynamic Client Registration: the authorization server advertises a registration endpoint, the client posts its metadata, and gets back credentials. If you are the one registering — we do this when connecting outward to other providers — cache the result per issuer. Registering on every startup gives the other side a slow leak of duplicate clients with your name on them, and they will eventually notice.

The newer one, which the late-2025 MCP work leans on, inverts it: instead of registering, the client hosts its own metadata document at a stable HTTPS URL and uses that URL as its client identifier. No registration call, no credentials to store, and the authorization server fetches the document when it needs to validate you.

Two consequences of that are easy to miss. The document must be publicly reachable, with no authentication in front of it, because the authorization server fetches it directly and it is not carrying your cookies. And the URL must be stable forever, because it is not a pointer to your identity — it is your identity.

Four failures in a row, each hiding the next

The most useful thing I can share is not the final configuration. It is the order the failures arrived in, because each one masked the next, and each surfaced as an error message about something else entirely.

We were connecting an AI builder to our MCP server. It reported, simply, that the server did not support OAuth.

Failure one: two of the three discovery URLs

The builder was right that discovery failed, and wrong about why. Our resource metadata was correct: the endpoint returned 401 with the right hint, and the protected-resource document returned 200 and named the authorization server.

The problem was the next hop. When the issuer has a path component — which every realm-per-path identity server has — the spec requires a client to try three URLs, in order:

1  https://example.com/.well-known/oauth-authorization-server/auth/realms/myrealm
2  https://example.com/.well-known/openid-configuration/auth/realms/myrealm
3  https://example.com/auth/realms/myrealm/.well-known/openid-configuration

The first two insert the well-known segment before the issuer path. The third appends it, which is the form the identity server actually serves. Ours answered 200 on the third and 401 on the first two, because they did not match any known route and fell through to the application's authentication.

The specification says clients must try all three. This client stopped at the first non-success and reported the generic failure. You can be annoyed about that, or you can notice that "clients must" is doing a lot of work in a sentence describing software you do not control.

The fix was an ingress rule proxying both path-insertion forms to the metadata the server already produces, unauthenticated, with the issuer value byte-identical in all three responses. A different issuer string in one of them is worse than a 401: the client gets a document and then fails validation later, somewhere less obvious.

If your issuer has a path, all three discovery URLs are your public API. Serving one of them is serving none of them, for any client that picks a different order than you assumed.

The cleaner long-term answer is a pathless issuer on its own hostname, which makes the question disappear. That is a migration rather than a fix, so the proxy rule is what makes the flow work today.

Failure two: trusted hosts, reported as a scope problem

Discovery fixed, the client advanced to registration and failed again. What it displayed was 403 insufficient_scope.

What the identity server's log actually said was that its anonymous registration policy (Trusted Hosts) had rejected the caller because the caller's network address was not in the trusted-hosts list.

Those two statements have nothing to do with each other. The client turned a policy rejection into the nearest OAuth error it recognised, and that error sends you to look at scopes — which were fine. The log line is the only place the truth existed.

The underlying conflict is real, though, and worth stating plainly. Anonymous DCR with host verification assumes you know your callers' network origins in advance. Cloud-hosted AI clients do not have stable egress addresses, and you do not want to track them if they did. So the host check has to come off — and the moment it does, anyone can register a client.

What we kept instead of it:

  • Callback URI verification (client-uris-must-match) stays on, restricted to the vendor's domains. This is the control that actually matters: an attacker can register a client, but cannot point it anywhere they can receive a code.
  • Consent stays required, so a human sees what is being granted.
  • Full scope access stays off, so a new client gets nothing by default.
  • Allowed protocol mapper types stay restricted.
  • A cap on total clients stays in place.

That trade — drop the check that assumes stable infrastructure, keep the checks that constrain what a registered client can do — is the whole decision, and it is the one thing on this list I would not delegate to a runbook.

Failure three: the scopes exist, but new clients do not get them

Registration then succeeded, and the connection failed with: invalid scopes.

Both scopes existed. The registration policy explicitly allowed them. The dynamically-created clients simply did not have them attached, because permitting a scope during registration and assigning it to the client that gets created are different settings.

The fix is to add them as realm-level default optional scopes. Optional, not default: the client has to ask, and the user has to consent, which is the behaviour you want and also the behaviour that makes the consent screen meaningful.

One detail that costs a retry if you miss it: realm defaults are applied when a client is created. Changing them does nothing to the clients that already exist. You have to trigger a fresh registration, and clean up the failed ones afterwards.

Failure four: there wasn't one

The next attempt completed: registration, authorization, consent, and a full tool catalogue loaded in the client.

Four failures, each invisible until the one before it was fixed, and three of the four reported as something other than what they were. Discovery failure reported as "no OAuth support". Host policy reported as insufficient scope. Missing scope assignment reported as invalid scopes, which is the closest to true and still points at the wrong object.

The Keycloak specifics

This is the part you cannot find in the specs, because it is not in them.

The audience comes from the client scope, not the client

This is the one that cost the most time. You configure a client, you look for where to set the audience, and there is a place on the client that looks like it. Meanwhile the mapper that actually stamps the audience onto the token belongs to the client scope.

The symptom is that everything looks configured, the login succeeds, the token is valid, and the MCP server rejects every request for an unexpected audience. The client has no mappers of its own and that is correct — which makes the correct state and the broken state look identical from the client screen.

Our verification tooling now prints the audience per scope, with a loud NONE when a scope has no audience mapper, precisely because that is the field whose absence is invisible everywhere else.

Your verification tool can lie to you

We used the admin CLI's field selector to check client settings, including whether PKCE was enforced. It reported PKCE as unset on clients that were enforcing it.

The reason is that the selector renders map-valued fields, like the attributes bag where PKCE enforcement lives, as an empty object even when populated. So the tool reported the absence of a setting that was present.

A verification tool that lies is worse than no verification tool, because it converts "I should check" into false confidence. We now fetch the whole client representation and pick fields locally. Same information, one more step, no lying.

Realm import does not import into a realm that exists

Keycloak's realm import runs only when the realm is absent. Every environment past the first has a realm already, so the import file provisions new environments and silently does nothing to running ones.

If you keep OAuth client definitions in the realm file and assume they roll out, they do not, and nothing tells you. We ended up with two paths that read the same source of truth: the realm file for fresh environments, an idempotent apply script for live ones. The important property is the shared source, not the script.

Match the admin CLI version to the server

The admin CLI is not guaranteed compatible across versions in either direction. A newer one can send fields the server rejects; an older one can quietly drop fields you set. If there is a running instance, use its own CLI. If not, pin the image to the server's major and minor.

Consent breaks the convenient test path

We set consent required on the client that end users authorize, which is correct: people should see what they are granting. It also disables the direct password grant, which is the grant everyone reaches for when scripting an end-to-end test.

That is the system working as designed, so plan for a browser-based test from the start rather than meeting this at the moment you wanted a fast loop. If you relax it to get a test through, restoring it belongs in the same change — never in a follow-up.

Issuer and key location are different concerns

In production the issuer URL and the key set URL are the same host and you should not think about it. Locally they come apart: a token minted through a browser carries the issuer the browser used, so validation must expect exactly that, while a container cannot reach the host's loopback address to fetch keys.

Trying to satisfy both with one value produces a loop where fixing validation breaks fetching and fixing fetching breaks validation. They are separate settings because they are separate concerns: one is an identity assertion, the other is a network route.

Two traps on the framework side

If you are on Spring Security, declaring a decoder bean to handle your new audience will make the framework's auto-configuration back off — and your other authentication chain, the one that was working, starts demanding the new audience too. Build that decoder inline for the chain that needs it and leave the auto-configuration alone.

The second is ordering, and it bites whenever a new credential type joins an existing one. Ours already accepted API keys. Adding JWTs meant the API key filter had to stop rejecting things that are not API keys and start passing them through, and the bearer-token resolver had to return nothing for values that are API keys — because the JWT filter does not check whether something has already authenticated the request. Neither change is clever. Both are invisible in a diff and total in effect.

Error semantics are part of the interface

Three things we changed after watching real failures.

A conflict is not a forbidden. We had a case where a person was authenticated and real, but their account record pointed at an identity that no longer existed. That returned a server error, which says "something broke, try again" — and retrying could never work. It is a 409: you may, but the account state has to be sorted out first. The distinction is whether the caller can do anything about it, and that is exactly what a status code is for.

Do not name the other party in the error. The obvious body for that conflict explains which identity holds the account. That is a probing oracle: submit assertions, read back facts about accounts you do not own. Both identities go to the server log. The response says a conflict exists and what to do.

Surface upstream reasons verbatim when the consumer is a model. When our API says a particular scope is required, or that it does not recognise the asserted actor, we pass that through rather than flattening it to a status code. A model reading the sentence takes a different action. A model reading a bare 403 retries, then retries again.

Two operational notes

Health probes must not touch the identity provider or the upstream API. It feels more honest to check your dependencies. What it actually does is convert their outage into your restart loop — your service gets killed for being unable to reach something that is down, and then cannot come back to serve the requests that would have worked.

Cache your service tokens with a refresh margin, and collapse concurrent refreshes into one. Without that, the moment a token expires every in-flight request mints its own, and your identity provider takes a burst of identical grant requests at exactly the moment it is least convenient.

If you are starting this

A short version of what we would tell ourselves at the beginning:

  • Decide first that the user's token stops at the MCP server, and that upstream is called as a service with an asserted actor. Every later decision falls out of that.
  • Prove the audience boundary with a real token for the wrong audience. Do this on day one, not at the end.
  • Make the 401 point at the metadata document before testing any client, or you will debug a discovery failure as a credentials failure.
  • Support both dynamic registration and the client metadata document, and fail loudly when an issuer offers neither.
  • In Keycloak, check the audience mapper on the scope, not the client. Do not trust the field selector for anything map-valued.
  • Assume the realm file does not reach live environments, because it does not.
  • Shape-validate asserted identity claims before anything can be created from them. A trusted caller is not the same as a correct one, and just-in-time provisioning turns a malformed assertion into real rows.

None of this is conceptually difficult. Nearly all of it fails silently, which is the only reason it is worth writing down.