Authentication
This page documents how ModelContextProtocol.jl authenticates HTTP requests. It is precise about what the package does and — just as importantly — what it does not do, because the security of your server depends on understanding the boundary.
What this package is, and is not
The package implements an OAuth 2.1 Resource Server (RS) for the Streamable HTTP transport, per the MCP 2025-11-25 authorization specification:
- It validates bearer tokens presented on incoming requests (signature and/or claims, or remote introspection).
- It advertises where tokens come from via RFC 9728 Protected Resource Metadata.
- It attaches the authenticated principal to each request so tool handlers can make authorization decisions.
It is not an Authorization Server (AS). It does not issue tokens, run login or consent screens, implement Dynamic Client Registration, or handle PKCE redirects. Those are the job of an external AS — Keycloak, Auth0, Okta, an in-house IdP, or GitHub. The Deployment guide walks through standing one up end-to-end. The division is deliberate: a Resource Server is a small, auditable surface; an Authorization Server is thousands of lines of security-critical token machinery that you should not hand-roll.
The stdio transport is unauthenticated by design — it is a local subprocess pipe, so the trust boundary is the operating system, not a token. Everything below concerns the HTTP transport.
The request lifecycle
When auth is configured on the HTTP transport, every request (except the public metadata endpoint, below) goes through authenticate_request:
- Extract the bearer token from the
Authorization: Bearer <token>header. A missing header →401; a malformed header →401. - Validate the token with the configured
TokenValidator. What this checks depends on the validator (signature, claims, or remote introspection). - Allowlist (optional): if an allowlist is set, the authenticated principal's username or subject must be in it, otherwise
403. - On success, the
AuthenticatedUseris carried to the tool handler asctx.authenticated_user.
Transport-level authentication failures never reveal why a token was rejected (expired vs. wrong issuer vs. bad signature vs. a missing server-wide scope). The client receives a fixed, generic OAuth error so the endpoint cannot be used as a token/policy oracle; the specific reason is retained only for server-side logging. (Per-tool required_scopes are different: they are checked after authentication, and the -32004 error does name the missing scope — see Per-tool authorization.)
| Outcome | HTTP status | WWW-Authenticate |
|---|---|---|
| No token | 401 | Bearer resource_metadata="…" |
| Invalid/expired/forged token | 401 | Bearer error="invalid_token", … |
| Valid token, missing scope or not allowlisted | 403 | Bearer error="insufficient_scope", … |
When resource_metadata is configured on the transport (strongly recommended whenever auth is enabled), the WWW-Authenticate header points the client at the Protected Resource Metadata document (below) — how a compliant client discovers your Authorization Server and starts a token flow. Without it the header still signals the error but carries no discovery pointer.
Wiring auth into the HTTP transport
Two keyword arguments on the HTTP transport turn auth on:
using ModelContextProtocol
auth = create_auth_middleware(
OAuthConfig(
issuer = "https://auth.example.org/realms/main", # expected `iss`
audience = "https://mcp.example.org/mcp", # expected `aud`
required_scopes = ["mcp:read"], # required on every request
),
validator = JWKSValidator("https://auth.example.org/realms/main/protocol/openid-connect/certs"),
allowlist = Set(["alice", "bob"]), # optional
)
meta = ProtectedResourceMetadata(
resource = "https://mcp.example.org/mcp",
authorization_servers = ["https://auth.example.org/realms/main"],
scopes_supported = ["mcp:read"],
)
server = mcp_server(name = "secure-server", tools = [...])
server.transport = HttpTransport(host = "127.0.0.1", port = 8080,
auth = auth, resource_metadata = meta)
connect(server.transport)
start!(server)auth::Union{AuthMiddleware,Nothing}—nothing(the default) disables auth.resource_metadata::Union{ProtectedResourceMetadata,Nothing}— served, unauthenticated, at the transport's/.well-known/oauth-protected-resourcepath (clients must read it before they have a token). Note the distinction between that backend path and the advertised URL: the URL inWWW-Authenticateis built from the metadata'sresourcefield —<resource>/.well-known/oauth-protected-resource— so withresource = "https://mcp.example.org/mcp"clients are pointed athttps://mcp.example.org/mcp/.well-known/oauth-protected-resource. Make sure your proxy routes that public path back to the transport (see Deployment).
HttpTransport does not terminate TLS and does no network-level access control. Put it behind a TLS-terminating reverse proxy (see Deployment) and bind it to 127.0.0.1 (or a LAN address the proxy can reach), never directly to a public interface.
OAuthConfig
OAuthConfig describes what a valid token must look like, independent of how it is verified:
OAuthConfig(;
issuer::String, # expected `iss` claim
audience::String, # expected `aud` claim (your resource)
required_scopes::Vector{String} = String[], # all must be present on every request
jwks_uri::Union{String,Nothing} = nothing, # informational
introspection_endpoint::Union{String,Nothing} = nothing, # used by IntrospectionValidator
)issuer and audience are fail-closed: when set, a token that lacks a matching iss/aud is rejected (a forged token cannot pass by simply omitting the claim). audience matches a string aud exactly, or membership when aud is an array. Leave a field empty ("") only if you deliberately want to skip that check.
required_scopes is checked on every request by the claims-based validators — JWTValidator, JWKSValidator, and IntrospectionValidator. JWTValidator/JWKSValidator read the JWT's scope (space-delimited string) or scp (array) claim; IntrospectionValidator reads the scope field of the RFC 7662 introspection response. The SimpleTokenValidator and GitHubOAuthValidator do not consult OAuthConfig.required_scopes; with those, enforce authorization through the allowlist or in your handlers. For per-tool scopes (a write tool that needs mcp:write while reads need only mcp:read), declare MCPTool(required_scopes = [...]) — see Per-tool authorization below.
The validator ladder
A TokenValidator decides whether a token is genuine. Pick by token type and trust model — they are ordered here from least to most appropriate for tokens from an external issuer.
| Validator | Token type | Verifies | Use when |
|---|---|---|---|
SimpleTokenValidator | opaque string | static map lookup | dev / trusted static API keys |
JWTValidator | JWT | claims only — no signature | dev/test, or behind a gateway that already verified the signature |
JWKSValidator | JWT | signature (JWKS) + claims | tokens from an external AS — recommended |
IntrospectionValidator | opaque or JWT | remote call to the AS (RFC 7662) | opaque tokens you can't verify locally |
GitHubOAuthValidator | GitHub access token | GitHub /user API call | authenticating GitHub users directly |
JWKSValidator — signature verification (recommended)
JWKSValidator(jwks_uri::String;
allowed_algs = ["RS256", "RS384", "RS512"],
clock_skew_seconds = 60,
refresh_interval_seconds = 300,
allow_insecure_http = false)
JWKSValidator(keyset::JWTs.JWKSet; kwargs...) # pre-built / static key setVerifies the token's RSA signature against the issuer's JSON Web Key Set (RFC 7517), then applies the same fail-closed claim checks as JWTValidator. This is the correct choice for any token minted by an external Authorization Server. Security-relevant behavior, in order:
- Algorithm allowlist, before any cryptography. A token whose header
algis not inallowed_algsis rejected immediately. This is what blocks the classicalg=nonebypass and the RS256→HS256 key-confusion attack. The default permits the RSA family only; do not addHS*(HMAC) algorithms for keys published in a public JWKS. kidrequired. Tokens without a key id are rejected — the validator never guesses which key to use.- Lazy, rate-limited key loading. Construction never touches the network, so the server starts even while the AS is down (requests fail closed until keys load). An unknown
kidtriggers at most one JWKS re-fetch perrefresh_interval_seconds(default 300), so an attacker spraying randomkidvalues cannot hammer the JWKS endpoint. Fetches use bounded timeouts, a 1 MB response cap, and never hold the validator's lock during network I/O. - Plaintext rejected. An
http://JWKS URL is refused at construction (a network attacker could swap in their own signing key) unless you passallow_insecure_http = truefor localhost/testing.https://andfile://URLs, and a directly-injectedJWTs.JWKSet, are supported. - Malformed upstream fails closed. A garbage or oversized JWKS document fails authentication while retaining the previously cached keys, rather than erroring the request or dropping all keys.
clock_skew_seconds (default 60) is the tolerance applied to exp/nbf: a token is only rejected once it is more than clock_skew_seconds past its expiry. This is standard practice for cross-host clock drift; lower it if your RS and AS share a clock.
JWTValidator — claims only (no signatures)
# `insecure_skip_signature_verification` defaults to `false`, and the constructor THROWS
# unless you pass `true` — an explicit acknowledgement that signatures are not verified:
JWTValidator(insecure_skip_signature_verification = true, clock_skew_seconds = 60)JWTValidator decodes and validates JWT claims but does not verify the cryptographic signature. Because the signature is unchecked, any caller can forge a token carrying the expected iss/aud/scopes — "trusting the issuer" is not, by itself, enough (it rejects alg=none, but that is no substitute for verification). To prevent accidental insecure deployment it refuses to construct unless you pass insecure_skip_signature_verification = true. Use it only in development/testing, or when a trusted component in front of the server (e.g. a gateway) has already verified the signature. For tokens that arrive directly from a client, use JWKSValidator.
IntrospectionValidator — RFC 7662
IntrospectionValidator(; client_id = nothing, client_secret = nothing)POSTs the token to the AS's introspection endpoint (OAuthConfig.introspection_endpoint) and trusts the AS's verdict. Appropriate for opaque tokens that cannot be validated locally. It enforces active == true, and binds iss/aud fail-closed when those are configured (so a token active at a shared AS but minted for a different resource cannot be replayed against yours). A numeric exp in the response, if present, must be in the future — but the validator does not require the AS to return exp. Each request is a network round-trip to the AS.
GitHub
auth = create_github_auth(;
allowed_users = ["alice", "bob"], # GitHub logins; empty = any authenticated user
required_org = "JuliaSMLM", # optional: require *active* org membership
cache_ttl_seconds = 300,
case_insensitive_allowlist = true) # fold username case (default)Validates a GitHub access token by calling GitHub's /user API, with a short-lived cache. required_org additionally requires active (not pending) membership in the named organization. This authenticates GitHub users directly against the package — a different model from brokering GitHub through an Authorization Server (which is what the Deployment guide does, and which the package sees as ordinary JWTs).
Assembling the middleware
create_auth_middleware ties an OAuthConfig to a validator:
create_auth_middleware(config::OAuthConfig;
validator::TokenValidator, # REQUIRED — no default
allowlist::Union{Set{String},Nothing} = nothing,
case_insensitive_allowlist::Bool = true, # fold username case (see Allowlists)
enabled::Bool = true) -> AuthMiddlewarevalidator is required and has no default — the package will never silently select an unsafe validator for you. Two convenience constructors exist: create_simple_auth (a Dict of API keys → usernames) and disable_auth (an explicit, clearly-named no-op for development).
Allowlists
When an allowlist::Set{String} is configured, a successfully authenticated principal must additionally have its username or subject in the set, or the request is 403. This is the simplest authorization model: "valid token and on the list."
By default the username comparison is case-insensitive (case_insensitive_allowlist = true), because brokered identity normalizes case — Keycloak, for example, lowercases federated usernames, so a GitHub login Alice arrives as alice in the token. The opaque OAuth subject is always matched exactly (case-folding a stable identifier could collide two principals). Pass case_insensitive_allowlist = false (on AuthMiddleware or any create_*_auth constructor) for exact username matching.
Per-tool authorization
OAuthConfig.required_scopes gates the whole server. To require a scope for a specific tool, declare required_scopes on the tool:
MCPTool(
name = "delete_record",
description = "Delete a record.",
parameters = [ToolParameter(name = "id", description = "record id", type = "string", required = true)],
required_scopes = ["mcp:write"],
handler = (args) -> TextContent(text = "deleted $(args["id"])"),
)At tools/call dispatch — before the handler runs, and before the task/sync split so both execution paths are gated — every scope in required_scopes must be present on the authenticated principal's scopes. On a miss the call is refused with a JSON-RPC -32004 (INSUFFICIENT_SCOPE) error naming the missing scope(s), and the handler never runs. required_scopes is server-side policy and is not emitted in tools/list.
The exact predicate is ctx.authenticated_user !== nothing: the check runs whenever the request carries an authenticated principal. With auth = nothing (no middleware) authenticated_user is nothing and the check is skipped — the server performs no authorization at all. disable_auth is not the same: it yields an anonymous AuthenticatedUser with no scopes, so a tool with non-empty required_scopes is denied under it, not skipped — don't rely on disable_auth() to bypass per-tool scopes. required_scopes is therefore meaningful only alongside an auth middleware whose validator populates scopes: JWTValidator/JWKSValidator read the JWT's scope/scp claim, and IntrospectionValidator reads the introspection response's scope.
For authorization that depends on the arguments rather than a static scope set, inspect the principal inside a context-aware handler and return a tool-level error instead:
MCPTool(
name = "transfer",
description = "Move funds; large transfers also need 'mcp:admin'.",
parameters = [ToolParameter(name = "amount", description = "amount", type = "number", required = true)],
required_scopes = ["mcp:write"], # baseline gate, enforced at dispatch
handler = (args, ctx) -> begin
scopes = ctx.authenticated_user === nothing ? String[] : ctx.authenticated_user.scopes
if args["amount"] > 10_000 && !("mcp:admin" in scopes)
return CallToolResult(
content = [TextContent(text = "insufficient_scope: transfers over 10000 require 'mcp:admin'")],
is_error = true)
end
TextContent(text = "transferred $(args["amount"])")
end,
)The principal is available as ctx.authenticated_user (an AuthenticatedUser, or nothing when the transport has no auth configured; disable_auth() instead yields an anonymous AuthenticatedUser) with .username, .subject, .scopes, .provider, and the raw .claims. A declarative required_scopes miss surfaces as a JSON-RPC error (the call was refused before running); a handler returning CallToolResult(is_error = true) reports a tool-level failure to the model — both are distinct from the transport-level 403 a client gets for a server-wide scope/allowlist failure, which the model never sees.
Protected Resource Metadata (RFC 9728)
A compliant MCP client that hits a 401 reads the WWW-Authenticate: … resource_metadata="<url>" header, fetches that document, and learns which Authorization Server to obtain a token from. Construct it directly or with a helper:
meta = create_protected_resource_metadata(
"https://mcp.example.org/mcp", # this resource
["https://auth.example.org/realms/main"], # its authorization server(s)
scopes = ["mcp:read"])It is served, unauthenticated, at /.well-known/oauth-protected-resource. For GitHub, create_github_resource_metadata fills in GitHub's authorization server URL.
Security checklist
- Prefer
JWKSValidatorfor external tokens; reach forJWTValidatoronly in dev/test or behind a component that has already verified the signature. - Always set
issuerandaudienceinOAuthConfig— they are the difference between "a valid token" and "a valid token for this server." - Terminate TLS in front of the transport; never expose the plaintext HTTP port. Never log bearer tokens.
- Do not pass a client's token through to an upstream API. If you must call an upstream, exchange the token (RFC 8693) or use the server's own credentials.
- Keep the error surface generic (the package already does this) — don't add tool output that leaks why authentication failed.
See Deployment for a complete, reproducible setup behind a reverse proxy with a real Authorization Server.