← 2026

Hide MCP Behind OAuth2

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:

A request goes through the gateway, which checks the token with Istio and OPA before it reaches the MCP server

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:

  1. The client calls the MCP endpoint without a token.

  2. OPA answers 401 with a WWW-Authenticate header that points to the protected resource metadata (RFC 9728):

    WWW-Authenticate: Bearer resource_metadata="https://mcp.localhost/.well-known/oauth-protected-resource"
  3. 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"]}
  4. The client runs the authorization code flow with PKCE against Authentik, opens the browser, the user signs in, and the client gets a token.

  5. 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

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.