DEV Community

Takashi Matsuyama
Takashi Matsuyama

Posted on Originally published at blog.tak3.jp

Enforce, Don't Issue: Remote MCP with OAuth Without Becoming an Authorization Server

Kozou is an open-source tool that exposes structured context from your PostgreSQL database to AI agents — over MCP and a REST API. This post is the record of a design decision: what happened when that MCP surface moved to a remote URL and needed OAuth. The capability shipped in v1.13.0, but the release is not the subject — why Kozou never grew its own authorization server is. Everything below is as of v1.19.0.

The moment you put an MCP server on a remote URL, authentication stops being a footnote and becomes the whole question. Kozou's own "before" state was honest about it: with no auth block configured, a remote call had exactly one shape available — every caller shared a single fixed execution.role. That's fine for a local, single-user setup and useless for anything multi-tenant, because there's no such thing as who is calling. Kozou v1.13.0 is the release where per-caller identity becomes possible at all.

What landed was a choice of mode

Kozou v1.13.0 (released 2026-07-13) brought remote MCP with OAuth (resource-server mode). The MCP transport can now authenticate callers with OAuth, so the execution tool that touches data (call) runs as the verified token's role rather than one shared role. The tools that describe the schema are gated on the scopes the token carries, and read a shared schema context.

The mode matters, and it is the whole point of this post. Kozou did not grow its own authorization server. It became an OAuth resource server: it validates the tokens someone else issued and enforces what they're allowed to do. The setup guide lives at kozou.org/guides/mcp-oauth/, and I'm deliberately not going to reproduce it here — this post is about why the design looks the way it does, not how to configure it.

The posture came first

The tempting story is "the MCP spec says split the roles, so Kozou split the roles." That's backwards. The posture was published first.

On 2026-06-08 — more than a month before v1.13.0 — Kozou's auth posture was already public and settled: Kozou is an enforcement layer; it does not issue identity. That wasn't an aspiration. A week earlier the REST surface had already shipped it: JWT auth and Postgres RLS enforcement landed in #54 on 2026-06-01 and went out in v0.2.0 — validate a JWT against a JWKS endpoint, SET LOCAL ROLE to the identity that token carries, and let Postgres row-level security do the actual enforcement.

So when remote MCP needed authentication, there was no design decision to agonize over. The resource-server shape wasn't chosen to conform to a spec — the shape was already there, and the MCP authorization spec, revision 2025-11-25 happens to describe exactly that split: a resource server that enforces, an authorization server that issues, and a clean boundary between them. v1.13.0 is the JWT → SET LOCAL ROLE → RLS pipeline, already load-bearing on REST, extended onto the MCP transport. Same posture, one more surface.

The order is the argument. The implementation on 2026-06-01, the posture written down on 06-08, the extension onto MCP on 07-13 — code first, words second, the new surface last. "Not chosen to conform to a spec" means exactly that sequence: something already running turned out to match what a later spec described.

What not running an authorization server buys you

Declining to be an authorization server is not a gap in the feature set. It's what makes the rest coherent:

  • Kozou holds no credentials. There are no user accounts and no identity credentials to store — so there's no token store to breach. It never sees a password. What it checks is a signature and the conditions that come with it — algorithm, expiry, issuer, audience.
  • You don't have to change identity providers. Because Kozou validates whatever your authorization server issues, enterprise SSO isn't a special integration — it's the same JWKS validation, pointed at your identity provider. That is not the same as nothing to do: your IdP has to be configured to put the right audience, the mcp:* scopes, and a role claim into the token. Getting that wrong is the first thing people trip on — it is the 403 below.
  • For anything that touches data, the final authorization decision is path-independent. The last word belongs to Postgres RLS, so a query gets the same policy whether it arrived over REST or over MCP's call. The transport changes; the rules don't.

That last point is the quiet payoff. When identity is enforced at the database rather than re-implemented per transport, adding a surface doesn't mean re-deriving your access model — it inherits it.

The MCP surface is deliberately stricter

Here's the part that's easy to miss: the MCP surface is not a copy of the REST surface with a token check bolted on. It is deliberately narrower about what it will accept.

The reason is not "the caller is remote and probably not a human." Put it there and the argument collapses — the person driving claude.ai is a human, and not one of these rules relaxes for them. Two other things drive it, and both have the same shape: one more thing you do not control.

First, you hand your tokens and your advertised metadata to a client you don't run. A remote MCP caller is a hosted client — claude.ai, ChatGPT, Claude Code — the protected-resource metadata Kozou publishes travels to it, and bearer tokens travel to the URLs that metadata names.

Second, you are not the one granting roles. Identity arrives from a federated directory: put Google Workspace behind Keycloak or Auth0, and every first-time user is a principal who authenticated fine and whom nobody assigned a role to.

On REST, Kozou will honor an anonymous role and a default role if you configure them. On MCP it deliberately won't — this is where the second reason bites:

  • No anonymous access, no default role. A default role here would silently grant authority to a principal your IdP admin never assigned one to — in a federated directory, that is every first-time user. With no role claim there is no clear authority to execute under, so the server fails closed rather than guessing.
  • Enabling execute requires a non-empty allowedRoles. You have to declare which roles a tool applies to; arbitrary role execution isn't granted by omission. A missing config can't quietly become a privilege escalation.

The remaining rules have no counterpart on REST at all. They come from the first reason — the tokens and metadata you hand to somebody else's client:

  • The URLs it advertises can't be plaintext http outside loopback. auth.resource and auth.authorizationServers are handed to third-party clients in the protected-resource metadata, and bearer tokens travel to them, so a non-loopback plaintext http URL isn't a warning — it's a startup error. (There's an explicit opt-in for an isolated test network, allowInsecureHttp; using it logs a startup warning.) What's checked is the advertised value, not the listener's TLS — binding Kozou to loopback and terminating https at a reverse proxy or tunnel in front of it is the normal shape.
  • The resource URI is never derived from the Host header. Headers can be spoofed, so Kozou won't trust the Host header for the kind of decision a DNS-rebinding attack would try to bend.
  • mcp:admin is a default scope, and is never advertised. Some clients echo the advertised list straight into their own registration request, so listing it would invite them to ask for an admin grant they never need. It stays out of scopes_supported.

The strictness isn't only about refusing things, though. When a token is missing a scope, Kozou (as a resource server) answers with an insufficient_scope challenge that names what's missing — a scope and a resource_metadata pointer — so a client capable of scope upgrade can then go re-authorize. The scopes it advertises are mcp:describe and mcp:execute. In practice this challenge shows up most often when a token carrying no recognized scope at all is refused at the door — usually a setup mistake: the IdP's mapper isn't emitting the scope claim, the audience is wrong, or the token was minted for a different client. With auth.resource set to https://mcp.example.com/mcp, that looks like this:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="mcp:describe", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
Content-Type: application/json

{"error":"This operation requires the \"mcp:describe\" scope."}
Enter fullscreen mode Exit fullscreen mode

The same challenge exists per tool, but it is far rarer. tools/list is filtered by scope — a tool whose scope the token lacks is never listed — so a per-tool 403 only happens if a client calls a name it was never shown, or cached tools/list and then had its scopes narrowed.

And the matching case with no credentials at all — per RFC 6750, a 401 with no error attribute, carrying a pointer to where the rules are advertised:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
Content-Type: application/json

{"error":"Missing or malformed Authorization header."}
Enter fullscreen mode Exit fullscreen mode

Both answers carry the same shape of JSON body. But when it is the token itself that failed, the body never says which check it failed — signature, expiry, audience, issuer all collapse into one generic message. A missing scope, which the client can resolve by re-authorizing, is named; a failed verification is not. Only what the client can act on goes in the WWW-Authenticate header.

That's the shape worth internalizing: refusal and discoverability are two halves of the same behavior. A strict server that only ever said "no" would be hostile; one that says "no, and here's precisely the scope you'd need and where to find the metadata" is strict and usable. On the discovery side, Kozou implements both the WWW-Authenticate and the well-known metadata discovery mechanisms, so a client can find the rules either way.

What came after — what used to be implicit became the operator's to declare

One of the things resource-server mode settled is that the resource URI is declared in configuration, never derived — because, as above, the Host header can't be trusted. That refusal to leave things implicit later spread to the deployments that use no OAuth at all.

It started with a mundane confusion. The port you bind is where you are listening. It is not where the client arrives. Those two agree only while nothing sits between them — put a proxy in front, run it through a tunnel, remap the published port, move it inside a devcontainer, and the agreement breaks. Kozou's connection page — the screen that hands a non-engineer a working config — was building the second address out of the first, with no way to correct it once they came apart. It would hand out, confidently, an address that was not the endpoint.

The OAuth side already had the answer. auth.resource is a declared value, never derived from a Host header. v1.18.0's server.mcp.http.advertisedUrl extends that to the deployments that don't configure auth (writing both is a config error). A line drawn against spoofing turned out to be right where nobody was attacking anything — where there was simply one proxy in the way. An unplanned dividend on a rule drawn for strictness.

There is a second one that looks like the same story. v1.17.0's server.mcp.http.enabled (default true) lets an operator declare that the MCP HTTP endpoint should not run. Until then the only lever was the bind address: the endpoint stayed up and its posture depended entirely on network topology. If you are never going to point an agent at it, the choice should be not to run it — not to look for somewhere to hide it.

That one, though, did not descend from OAuth. Its motivation at the time was that the posture had become a byproduct of network topology, which has nothing to do with a spoofable header. Not a descendant of the same rule — a different rule standing next to it. Put side by side they do face the same way, turning something implicit into something declared. I'll leave it at that.

Trade-offs, honestly

None of this is free, and a post that pretended otherwise wouldn't be worth reading:

  • You need an external authorization server. Resource-server mode means bringing your own issuer. The guide walks through two concrete recipes — Keycloak and Auth0.
  • Audience handling differs by IdP. Auth0 supports resource indicators (RFC 8707) natively; Keycloak needs a mapper to get the audience into the token. Same destination, different setup step.
  • Hosted authorization servers vary in their dynamic-registration behavior. This is squarely a client-to-AS concern rather than something the resource server decides, so I'll leave the specifics to the recipes above rather than half-explain them here.

Try it

The minimal setup, the per-IdP recipes, and the troubleshooting are all in one place: kozou.org/guides/mcp-oauth/. There's no point in my retyping the steps — the guide is the source of truth for how.

In summary

  • Kozou does remote MCP with OAuth as a resource server, not an authorization server — it validates tokens and enforces access, it doesn't issue identity.
  • That wasn't spec-chasing. The posture ("enforce, don't issue") was public and load-bearing on the REST surface first; the MCP authorization spec's resource-server / authorization-server split simply matched a shape that already existed.
  • Because the model for touching data is consistent end to end — JWT → SET LOCAL ROLE → Postgres RLS — the MCP surface can afford to be deliberately stricter than the REST one: no anonymous access, no default role, fail-closed by default, and refusals that tell the client exactly what scope is missing.
  • And "declare it, don't derive it" outgrew resource-server mode: advertisedUrl (v1.18.0, for the deployments that don't set auth — the config refuses the two together) handed the other postures an answer the OAuth side already had. A line drawn for strictness turned out to be the correct answer elsewhere.

It was written with the help of basou, a harness I'm building for steering AI coding agents.

Top comments (4)

Collapse
 
themineworks profile image
themineworks

The "declare it, don't derive it" rule for advertisedUrl is the same failure mode as trusting X-Forwarded-Host behind a reverse proxy, just one layer up in the OAuth metadata instead of the request itself. Worth flagging for anyone extending this pattern: if you're behind more than one valid external path (a direct load balancer plus a dev tunnel, say), whichever one config saw at declare-time gets baked into the resource metadata, and the failure looks identical to the audience mismatch you already call out as the most common 403, generic and hard to place from the client side.

We ended up at a third posture on this, worth naming since it's different from both roles in your post: we don't run as a resource server or an authorization server, we sit entirely behind Apify's own MCP gateway and trust its layer to have validated the caller before a request reaches us at all. It's the same instinct as "enforce, don't issue" pushed one level further, delegate enforcement too, not just identity issuance, which works because the platform's billing is also gated on the same boundary, so there's no separate incentive to skip the check. The tradeoff is the one you'd expect: zero OAuth code to maintain, but zero control over the posture either, if the gateway's check is ever wrong we have no independent backstop.

Collapse
 
takashimatsuyama profile image
Takashi Matsuyama

Thanks, @themineworks — You've got the shape right, with one layer correction: advertisedUrl never reaches the metadata. It's the declared address for deployments that configure no auth — the value the connection page hands an operator — and the config refuses it alongside an auth block. What gets baked into the protected-resource metadata is auth.resource. Your point lands there, and it lands hard.

auth.resource is a single scalar carrying three jobs: it's the resource in the RFC 9728 document, it's the default expected aud of every accepted token, and its hostname — only its hostname — is what gets added to the DNS-rebinding guard. I went and checked what a two-path deployment would actually take, and it's worse than you framed it. The guard's widening lever, allowedHosts, is a library option: no config key, no env override, and neither CLI entry point passes it through. So the second path isn't merely undeclared — from configuration it can't be admitted at all. It dies at 403 Host header is not allowed for this server. before a token is looked at, and the one partial lever left (the bind host) has to be an address the process can actually bind, which a tunnel hostname isn't.

One correction on the failure mode, though it turns out not to be the one that fires here: an audience mismatch isn't a 403. It's a 401 with error="invalid_token" and a body that says Invalid or expired token. and nothing more — signature, expiry, issuer and audience all collapse into that one message on purpose. The 403 in the post is insufficient_scope, which does name the scope and the metadata pointer. So "generic and hard to place from the client side" is a real cost and it's mine, but on your specific scenario the host check refuses the request first, which at least fails with a sentence that says what it is.

Where I did have an answer, and it isn't the host case: when jwt.issuer / jwt.audience are pulled away from what's advertised, startup says so. Widen the audience to accept a second URI and it warns, naming that URI, that the endpoint is now answering for a resource it doesn't advertise. There's no equivalent for the host set — and worse, the startup line that prints the accepted Host names tells the operator to "set allowedHosts to add more", which the config schema rejects. Filed as kozou#279 (the capability gap) and kozou#281 (the line that isn't true).

On the third posture: the strongest part of it isn't "zero OAuth code to maintain", it's the incentive argument. Billing gated on the same boundary means nobody gets to quietly decide the check is optional. That's structural, and most delegation stories don't have it.

The tradeoff you name is the axis this design answers differently — the last word belongs to Postgres RLS, so the independent backstop is the thing doing the enforcing rather than a second check sitting beside the first. But you can genuinely run Kozou in your posture, and I should be precise about what it costs, because it's more than identity. On MCP, execution collapses to a single fixed role: with no token there is no such thing as who is calling. On REST it's sharper — the unauthenticated path doesn't enter the role-switching transaction at all, so there's no SET LOCAL ROLE, no claims published, and any policy reading request.jwt.claims sees nothing. The RLS backstop I just claimed as the difference is exactly what the gateway posture switches off. The hybrid keeps it — gateway does transport auth, Kozou still validates the forwarded JWT — at the price of the OAuth config you deleted. Which one is right comes down to whether anything downstream needs to know which caller it is, and for plenty of tools it honestly doesn't.

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

Strong separation of issuer and enforcement. The extra boundary I’d test hard is the role claim → PostgreSQL role mapping: never pass an arbitrary token string into SET ROLE; map allowed issuer/audience/subject/group claims to a closed, versioned allowlist of DB roles, and reject unknown or ambiguous mappings. Keep SET LOCAL ROLE inside an explicit transaction and prove pooled connections return clean after success, error, cancellation, and timeout. I’d also exercise JWKS rotation/cache expiry, issuer-key confusion, audience mismatch, revoked users with still-valid access tokens, and role removal between token issuance and tool execution. Short token lifetimes help, but high-impact tools may still need dispatch-time reauthorization or a policy-version check. The resource server can avoid becoming an IdP while still owning these runtime authorization semantics.

Collapse
 
takashimatsuyama profile image
Takashi Matsuyama

Thanks again, @mads_hansen_27b33ebfee4c9 — This is the checklist I'd want to be held to, so let me walk it against what's actually in the tree rather than what I'd like to be there.

Closed allowlist, never an arbitrary string into SET ROLE. The identifier is quoted, the claims are always a bound parameter, and PostgreSQL's own role membership is the last gate — but the allowlist is asymmetric. On the MCP surface with execution enabled, a non-empty allowedRoles is a startup error when missing, and it's re-checked at dispatch. On REST it's optional. The asymmetry is deliberate (the federated first-time user is the MCP hazard), but "closed by default" is only true on one of the two surfaces.

Versioned. No. There's no policy-version concept anywhere — which is also why the dispatch-time policy check at the end of your comment has nothing to hang off yet.

Reject unknown or ambiguous mappings. Unknown, yes: a 403 that names the role. Ambiguous, no — and you found a real defect. A role claim that is present but not a string (an array, which is exactly what a Keycloak realm_access.roles or a group mapper emits) isn't rejected. It falls through to the default role, so on REST it becomes a silent downgrade the allowlist never sees; and where there is no default role, the refusal reads "Token does not specify a role", which is false — the token specifies one, it just isn't a string. Filed as kozou#280.

SET LOCAL ROLE inside an explicit transaction. Yes, and through one envelope shared by both authenticated surfaces so they can't drift: BEGIN (or BEGIN READ ONLY for reads) → SET LOCAL ROLEset_config(claimsGuc, …, true) → the work → COMMIT, with ROLLBACK on anything thrown. Both settings are transaction-local, so nothing survives into the pooled connection, and a ROLLBACK that itself fails releases the client as broken rather than returning it to the pool.

Prove pooled connections come back clean after success, error, cancellation, timeout. Weaker than I'd like, and checking it properly to answer you moved it down rather than up. Success, work-throws, COMMIT failure, and rollback-failure-destroys-the-client are unit tests against a fake client. Against a real Postgres there's a pool with max: 2 driven through eight rounds — but the 401 in that loop returns before a connection is acquired, so what it actually proves is release on the success path, not after an error. Cancellation and statement timeout aren't exercised at all; structurally they land on the work-throws path, but "structurally" isn't the proof you asked for, and that's the interesting quadrant.

Issuer-key confusion. Closed on the surface you're asking about, and the one item on your list I got to first: on the MCP resource server, when jwt.issuer isn't set explicitly the advertised authorization servers become the expected issuer, so iss is always verified — precisely so shared key material across realms can't be walked in. An explicit jwt.issuer still wins, because an operator who sets it means it. Same asymmetry as the allowlist, though: on REST there's no such default, so a deployment that omits jwt.issuer performs no iss check at all.

Audience mismatch. Verified; aud defaults to the resource URI, with a documented escape hatch for IdPs that can't mint it, and startup warns when that escape hatch widens the accepted set past what's advertised.

JWKS rotation and cache expiry. Delegated to jose's remote JWKS: kid-driven refetch, a 30-second cooldown and a 10-minute cache, and I expose no knobs for either. Unknown-kid rejection is tested; both JWKS tests publish a single key, so nothing exercises picking the right key out of several, and rotation isn't tested at all. Honest gap.

Revoked users with live tokens, role removal between issuance and execution. No introspection, no revocation check. What partly saves it is where the decision is made: in the database, at query time. Revoke a role or change an RLS policy and it takes effect on the very next call; a role the connecting user is no longer a member of makes SET LOCAL ROLE fail outright. What that doesn't catch is an IdP-side change with a still-valid token — the claim keeps saying what it said, and short lifetimes are the only answer there today.

Dispatch-time reauthorization for high-impact tools. Scopes and the role allowlist are both re-checked at dispatch, and callable functions have their own schema-qualified allowlist, so the surface is gated three ways — but all of it is evaluated from the token in hand, never against fresh policy. If the execute surface grows past "call an exposed function", that's the piece I'd have to add.

So: two items were already in place, one only on the surface you were asking about, one was a defect, and the rest are partial or absent — including one I had to walk backwards while writing this. "The resource server can avoid becoming an IdP while still owning these runtime authorization semantics" is the sentence I wish the post had made explicit: declining to issue identity is not declining to own what happens at execution time.