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_serversnames 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 expectedissfrom 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_targetat the token endpoint — theresourcevalue 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
resourceat all). The client must send your identifier as theresourceparameter. - 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.