Private betaZynth Auth is currently in private beta testing.New organizations are created by invitation only, and no plan can be purchased yet.Request early access

Securing MCP servers with Zynth Auth

The MCP specification requires OAuth 2.1: an MCP server is a resource server, MCP clients obtain access tokens from an authorization server, and tokens must be bound to the specific server they were minted for. Zynth Auth is that authorization server — you register your MCP server once, and every token we mint for it is verifiable with about ten lines of code.

The property doing the work: a token minted for your MCP server carries your server's identifier as its audience. It verifies only at your server — it is rejected by the Zynth API and by every other resource. A stolen token cannot be replayed anywhere else, which is the confused-deputy defence the MCP spec exists to force (RFC 8707).

1. Register your MCP server as a resource

Settings → Organization → OAuth Resources → Register resource, or via the API (tenant:manage required):

curl -X POST https://auth.zynthmedia.com/api/v1/tenants/oauth-resources \
  -H "Authorization: Bearer $ZYNTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"identifier": "https://mcp.example.com/server", "name": "Production MCP server"}'

The identifier is the URI your server calls itself — an absolute https URI with no fragment (http://localhost:… is allowed for local development). It is an identifier, not a URL we ever call. Tokens are minted only for registered, enabled identifiers: an unregistered resource is refused with invalid_target, never defaulted — so if you see that error, the fix is registration, not retry.

Listing, renaming, disabling and removing resources is the rest of the OAuth resources API. Every write is audited and raises an immediate oauth-resource-changed detection — this registry decides which audiences your organization can have tokens minted for.

2. Point your MCP clients at Zynth

An MCP client asks for a token naming your server as the resource:

curl -X POST https://auth.zynthmedia.com/api/v1/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=$CLIENT_ID \
  -d client_secret=$CLIENT_SECRET \
  -d resource=https://mcp.example.com/server

The authorization-code + PKCE flow (what interactive MCP clients use) takes the same resource parameter on the authorize request. The restriction survives refresh: a rotated token keeps the same audience automatically, and an attempt to swap the resource mid-grant is refused with invalid_target.

Advertise Zynth as your authorization server with RFC 9728 protected-resource metadata — serve this at /.well-known/oauth-protected-resource on your MCP server:

{
  "resource": "https://mcp.example.com/server",
  "authorization_servers": ["https://auth.zynthmedia.com"]
}

Two issuers, deliberately. authorization_servers names the OIDC issuer — where discovery, JWKS, and id_tokens live. The access tokens your server verifies carry a different issuer: iss = "zynth-auth", the platform's access-token issuer, as pinned in the code below. Configure it explicitly; deriving the expected iss from the authorization-server metadata (as some resource-server libraries do by default) computes the OIDC issuer and rejects every token. Details: Verifying tokens.

3. Verify tokens at your server

Every request to your MCP server carries Authorization: Bearer <token>. Verify with our published keys — three checks, none optional: signature (JWKS), issuer, and audience (your identifier — this is the check that makes a token stolen from another resource useless at yours).

import jwt
import requests

JWKS_URL = "https://auth.zynthmedia.com/.well-known/jwks.json"
ISSUER = "zynth-auth"  # the ACCESS-token issuer — NOT the authorization server URL above
MY_RESOURCE = "https://mcp.example.com/server"

_jwks = {k["kid"]: k for k in requests.get(JWKS_URL, timeout=10).json()["keys"]}


def verify_mcp_token(token: str) -> dict:
    """Verify a Zynth-minted MCP token. Raises jwt.PyJWTError on ANY failure."""
    kid = jwt.get_unverified_header(token)["kid"]
    key = jwt.algorithms.RSAAlgorithm.from_jwk(_jwks[kid])
    return jwt.decode(
        token,
        key=key,
        algorithms=["RS256"],  # pin the algorithm — never accept the header's word
        audience=MY_RESOURCE,  # the check that binds the token to YOUR server
        issuer=ISSUER,
    )

The verified claims include sub (who), client_id (which application), scope, and tid (the Zynth organization). Cache the JWKS and refresh it on an unknown kid — that is how key rotation reaches you without a deploy.

Troubleshooting

  • invalid_target at the token endpoint — the resource value is not registered (or is disabled) for the organization. Register it first; the identifier must match exactly, including scheme and path.
  • Signature valid but audience check fails — the token was minted for a different resource (or with no resource at all). The client must send your identifier as the resource parameter.
  • Our own API returns 401 to your token — expected, by design. An audience-bound token works only at its named resource; calls to the Zynth API need an ordinary token.