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
executerequires a non-emptyallowedRoles. 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
httpoutside loopback.auth.resourceandauth.authorizationServersare handed to third-party clients in the protected-resource metadata, and bearer tokens travel to them, so a non-loopback plaintexthttpURL 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
Hostheader. Headers can be spoofed, so Kozou won't trust theHostheader for the kind of decision a DNS-rebinding attack would try to bend. -
mcp:adminis 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 ofscopes_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."}
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."}
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 setauth— 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 (2)
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.
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.