The problem
More and more MCP servers appear in a company. There is one for dashboards, one for logs, one for tickets, one for the database. Most of them are small and most of them have no authentication, or a static token in a config file. That is fine on a laptop. It is not fine when the server can read production data and sits on the internal network where everybody can reach it.
An MCP server should be exposed like any other internal service: only for people who are allowed to use it, with their own identity, and with a clear “no” for everybody else.
I described the idea of taking auth out of the services and moving it into the platform in Keep Auth Out of Your Services. MCP is the same problem, and the same solution works. This article is a small example of it.
The idea
The MCP server does not get any auth code. The ingress gateway does the work:
- Authentik is the identity provider. It issues the tokens and knows the users and groups. Any OIDC provider works the same way.
- Istio validates the JWT: signature, issuer, audience and expiry.
- OPA decides if the caller is allowed. In this example: the user must be
in the group
Observability MCP. - The MCP server (Perses MCP) is deployed as is. It is only reachable through the gateway.
How an MCP client finds the login
The MCP specification uses OAuth2 for HTTP servers, so the client does not need to be configured with a login URL. It learns it from the server:
-
The client calls the MCP endpoint without a token.
-
OPA answers
401with aWWW-Authenticateheader that points to the protected resource metadata (RFC 9728):WWW-Authenticate: Bearer resource_metadata="https://mcp.localhost/.well-known/oauth-protected-resource" -
The metadata document says which authorization server to use. It is static, so the gateway answers it directly, without any backend:
directResponse: status: 200 body: string: >- {"resource":"https://mcp.localhost/mcp", "authorization_servers":["https://auth.localhost/application/o/perses-mcp/"], "scopes_supported":["openid","profile","email","offline_access"]} -
The client runs the authorization code flow with PKCE against Authentik, opens the browser, the user signs in, and the client gets a token.
-
The client repeats the call with
Authorization: Bearer <JWT>.
The gateway
Istio checks the token. A request without a token passes this step on purpose,
because OPA has to answer it with the 401 above:
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
name: authentik
namespace: istio-ingress
spec:
selector:
matchLabels: {istio: ingress}
jwtRules:
- issuer: https://auth.localhost/application/o/perses-mcp/
jwksUri: http://authentik-server.authentik.svc.cluster.local/application/o/perses-mcp/jwks/
audiences: [perses-mcp]
fromHeaders:
- {name: Authorization, prefix: "Bearer "}
Then every request to the MCP host is sent to OPA:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: mcp-opa
namespace: istio-ingress
spec:
selector:
matchLabels: {istio: ingress}
action: CUSTOM
provider:
name: opa
rules:
- to:
- operation:
hosts: [mcp.localhost]
ports: ["443"]
The policy is short. Istio has already verified the token, so OPA only decodes it and checks the issuer and the group:
user_allowed if {
claims.iss == data.issuer
data.group in object.get(claims, "groups", [])
}
result := {"allowed": true, "headers": {"x-auth-subject": subject}} if {
not discovery
user_allowed
}
The rest of the policy returns 401 with the WWW-Authenticate header when
there is no token, and 403 when the user is not in the group. Discovery
documents under /.well-known/ stay readable without a token, otherwise the
client could never find the login.
One detail: the gateway removes the Authorization header before it forwards
the request. The MCP server uses its own Perses account (a bot user), not the
token of the person. So the user token never leaves the gateway, and Perses
sees one technical user.
Configure opencode
On the client side there is almost nothing to do. The Authentik application is a public client, so there is no secret, and opencode only needs its client id:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"perses": {
"type": "remote",
"url": "https://mcp.localhost/mcp",
"oauth": {
"clientId": "perses-mcp",
"scope": "openid profile email offline_access"
}
}
}
}
The client id is configured on purpose: Authentik does not offer dynamic client
registration, so the client cannot register itself. Then run opencode mcp auth perses.
The browser opens, you sign in to Authentik, and the tools of the Perses MCP
server show up in opencode.
Demo
The demo has two users. alice is in the group Observability MCP, and her
requests reach the MCP server. bob has a valid account and Authentik gives
him a token too, but the gateway answers 403. This is on purpose: the
identity provider says who you are, the policy says what you may use.
Things to know
- The MCP server must not be reachable around the gateway. It has no auth
of its own. In a real cluster add a
NetworkPolicyor mesh policy so only the ingress gateway can call it. - Everyone in the group acts as one Perses user. The audit log of Perses
shows the bot. The real user is in the OPA decision log (
x-auth-subject), so keep that log, and do not log tokens. - Rules are group-based. This is enough to answer “who may use this MCP server”. If you need “who may call this tool”, the MCP request body has the tool name, but then you are writing application-level policy.
Try it
The full example, with the Authentik blueprint, the gateway configuration and
the policies, is in the
blog-samples repository.
task up starts everything on a local kind cluster, and task test runs the
policy tests and an end-to-end check of the 401, the 403 and a real MCP call.