Skip to content

Authorization

Mocapi ships an OAuth2 resource-server module for the Streamable HTTP transport. The MCP 2026-07-28 authorization specification requires servers to:

  1. Validate bearer JWTs on every request (signature, expiry, audience).
  2. Respond with 401 WWW-Authenticate: Bearer ... resource_metadata="..." when a token is missing or invalid.
  3. Publish an RFC 9728 protected-resource metadata document at /.well-known/oauth-protected-resource advertising the accepted authorization servers.

mocapi-oauth2 wires all three, leaning on Spring Boot's oauth2-resource-server starter for the heavy lifting and filling in the MCP-specific gaps Spring doesn't address.

Getting Started

Add the starter to your build — it pulls mocapi-streamable-http-spring-boot-starter and spring-boot-starter-oauth2-resource-server transitively.

<dependency>
    <groupId>com.callibrity.mocapi</groupId>
    <artifactId>mocapi-oauth2</artifactId>
    <version>${mocapi.version}</version>
</dependency>

Configure your identity provider and the resource you're protecting:

Minimum configuration — for the common single-audience case, two standard Spring Boot properties are enough:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com
          audiences:
            - https://mcp.example.com

When spring.security.oauth2.resourceserver.jwt.audiences has exactly one element and mocapi.oauth2.resource is unset, mocapi auto-derives the protected-resource metadata's resource field from that single audience. For Auth0, Okta, Keycloak, and most other IdPs this is the normal case — clients get tokens for one logical resource, and there's no need to duplicate the identifier across two properties.

Full configuration — set mocapi.oauth2.* when you want to enrich the metadata document or work around the auto-derivation:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://idp.example.com
          audiences:
            - https://mcp.example.com

mocapi:
  server-title: My MCP Server                          # already used by server/discover;
                                                       # reused as OAuth2 resource_name
  oauth2:
    resource: https://mcp.example.com                  # optional — defaults to audiences[0]
                                                       # when audiences has exactly one entry.
                                                       # Must be a member of audiences.
    scopes:                                            # optional — advertised in metadata
      - mcp.read
      - mcp.write
    resource-documentation: https://docs.example.com   # optional — developer docs URL
    resource-policy-uri: https://example.com/policy    # optional — policy doc (token handling)
    resource-tos-uri: https://example.com/tos          # optional — terms of service

The metadata's resource_name field is sourced from mocapi.server-title (falling back to mocapi.server-name) — the same human-readable label the MCP server/discover response advertises. Having one property feed both avoids a configuration drift where the OAuth2 metadata names a different server than the MCP discovery surface.

spring.security.oauth2.resourceserver.jwt.issuer-uri and .audiences are the standard Spring Boot properties; mocapi does not duplicate them. The mocapi.oauth2.* properties cover the MCP-specific metadata document.

If you only set issuer-uri, Spring Boot performs an HTTP call to the IdP's /.well-known/openid-configuration at startup (or on first request, via SupplierJwtDecoder) to discover the signing keys URL. Setting jwk-set-uri explicitly skips that discovery hop:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://example.auth0.com/
          jwk-set-uri: https://example.auth0.com/.well-known/jwks.json   # recommended
          audiences:
            - https://demo-api.example.com

Cuts one network dependency from the boot path and makes startup more robust in restricted network environments. Most IdPs publish their JWKS at <issuer>.well-known/jwks.json — Auth0, Okta, Keycloak, Entra ID all follow that pattern.

Startup-time invariant

Mocapi validates at startup that mocapi.oauth2.resource (whether explicitly set or auto-derived) is a member of spring.security.oauth2.resourceserver.jwt.audiences. If they don't agree, the app refuses to start with a descriptive error. The rationale: clients following the RFC 9728 metadata document request tokens bound to the advertised resource identifier; if that identifier isn't in the server's accepted audiences, every token the client obtains would be rejected during validation. Catching that at startup is much cheaper than a silently-broken deployment where every MCP request returns 401.

That's the whole setup. Starting your Spring Boot application brings up:

  • Bearer token validation on ${mocapi.endpoint:/mcp}/** (signature, expiry, audience) — Spring Boot auto-wires this from the jwt.* properties.
  • A 401 WWW-Authenticate: Bearer ... resource_metadata="..." challenge on missing or invalid tokens — Spring Security 7.0's built-in entry point handles this natively.
  • A GET /.well-known/oauth-protected-resource endpoint serving the RFC 9728 metadata document — mocapi wires Spring's filter with your configured fields.

What mocapi adds vs. what Spring provides

Capability Provided by
JwtDecoder (JWKS fetch, signature verify) Spring Boot auto-config (jwt.issuer-uri)
aud claim validation Spring Boot auto-config (jwt.audiences)
JwtAuthenticationConverter (claims → authorities) Spring Boot auto-config
Default SecurityFilterChain with oauth2ResourceServer() Spring Boot auto-config
401 WWW-Authenticate: Bearer ... resource_metadata="..." Spring Security 7.0 BearerTokenAuthenticationEntryPoint
/.well-known/oauth-protected-resource endpoint mocapi wires Spring's OAuth2ProtectedResourceMetadataFilter
Metadata document content (resource, authorization_servers, scopes_supported, etc.) mocapi, from mocapi.oauth2.* with fallback to jwt.issuer-uri
SecurityFilterChain scoped to the MCP endpoint + metadata path mocapi

Filter chain architecture

mocapi-oauth2 registers two SecurityFilterChain beans, not one. Each chain owns a distinct URL space, has its own authorization policy, and exposes its own customizer SPI.

Bean name @Order Matches Policy Customizer SPI
mcpMetadataFilterChain HIGHEST_PRECEDENCE /.well-known/oauth-protected-resource permitAll (RFC 9728 §3) McpMetadataFilterChainCustomizer
mcpFilterChain HIGHEST_PRECEDENCE + 10 ${mocapi.endpoint:/mcp} and ${mocapi.endpoint:/mcp}/** authenticated, or all of mocapi.oauth2.required-scopes when set McpFilterChainCustomizer

CSRF is disabled on both (MCP is stateless bearer-token, not cookie auth). Both chains wire the same McpTokenStrategy into Spring's oauth2ResourceServer DSL — the metadata chain uses it only to satisfy the DSL (which refuses to build without a bearer-token format declared); the MCP chain uses it to actually validate incoming tokens.

The chains are split because they have genuinely different responsibilities. The metadata chain serves a public discovery document — clients fetch it before they have a token to find out which authorization server to use. Requiring auth there would be a chicken- and-egg violation of RFC 9728. Keeping it on its own chain with its own customizer surface means tweaks to the MCP auth policy (like "require scope mcp.write") can't accidentally lock the metadata document.

Requiring a scope to reach the server (resource-level)

To require that every caller present a given scope just to talk to the MCP endpoint, set mocapi.oauth2.required-scopes:

mocapi:
  oauth2:
    scopes: [mcp.read, mcp.write]          # advertised in the metadata document
    required-scopes: [mcp.read]            # enforced on every /mcp request

The property is optional. Leave it unset and the endpoint requires only an authenticated token — the default. Set it and all listed scopes must be present (AND semantics, matching @RequiresScope). Values are bare scope names; the SCOPE_ authority prefix is applied for you.

A valid token missing a required scope gets:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
                  error_description="...", scope="mcp.read",
                  resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

That challenge is defined by RFC 6750 §3.1 and is the step-up breadcrumb the MCP authorization spec expects: it tells the client which scope to go ask the authorization server for, and the resource_metadata parameter (RFC 9728) points at the document listing what this server supports. Spring Security's BearerTokenAccessDeniedHandler produces it; mocapi only supplies the rule. A request with no token still gets 401, not 403 — missing credentials is an authentication failure, not a scope problem.

List every required scope in mocapi.oauth2.scopes too. That property is what populates scopes_supported in the metadata document, so a scope you enforce but don't advertise leaves clients no way to discover what to request — they just see a 403. Mocapi logs a warning at startup if it spots one.

Why this is a property and not a customizer

Earlier versions of this guide suggested doing it in an McpFilterChainCustomizer with auth.anyRequest().hasAuthority(...). That does not work, in two different ways, so don't reach for it:

  • HttpSecurity.authorizeHttpRequests reuses a single rule registry across calls, and anyRequest() refuses to be configured twice — mocapi already called it, so a second call fails the context at startup with Can't configure anyRequest after itself.
  • Switching to requestMatchers(...) to dodge that is worse: rules are matched in registration order, first match wins, and mocapi's anyRequest() rule is registered first, so a later rule is never consulted. You get no enforcement and no error — the dangerous outcome.

Resource-level scopes therefore have to be expressed where mocapi builds that single rule, which is what required-scopes does.

Customizing the MCP chain

McpFilterChainCustomizer mutates the HttpSecurity for mcpFilterChain after mocapi has applied its defaults (securityMatcher, authorization rule, CSRF disabled, oauth2ResourceServer). Use it for concerns that sit alongside the authorization rule rather than replacing it — CORS on the MCP endpoint, an extra servlet filter, a rate limiter:

@Bean
McpFilterChainCustomizer corsForMcp() {
    return http -> http.cors(Customizer.withDefaults());
}

Multiple McpFilterChainCustomizer beans compose in Spring's natural order. They run after mocapi's defaults, so user configuration layers on top of the built-ins. The one thing to avoid is authorizeHttpRequests — see the section above for why, and use required-scopes instead.

Customizing the metadata chain

McpMetadataFilterChainCustomizer targets the metadata chain. Typical uses: permit CORS so browser-based MCP clients can fetch the metadata document, add security headers, or front the endpoint with a rate limiter.

@Bean
McpMetadataFilterChainCustomizer metadataCors() {
    return http -> http.cors(cors -> cors.configurationSource(request -> {
        var config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("https://app.example.com"));
        config.setAllowedMethods(List.of("GET", "HEAD"));
        return config;
    }));
}

Don't touch authorizeHttpRequests on this chain. RFC 9728 §3 requires the metadata document to be fetchable without authentication, and mocapi freezes the policy at permitAll for that reason. This customizer is for HTTP-layer concerns (CORS, headers, logging, rate limiting) — not for auth policy.

Swapping the token strategy

McpTokenStrategy is the SPI that configures Spring's oauth2ResourceServer DSL with a bearer-token format. Mocapi ships two implementations, selected automatically by @ConditionalOnBean:

  • JwtMcpTokenStrategy — activates when Spring Boot wired a JwtDecoder (i.e. spring.security.oauth2.resourceserver.jwt.* is set).
  • OpaqueTokenMcpTokenStrategy — activates when Spring Boot wired an OpaqueTokenIntrospector (i.e. spring.security.oauth2.resourceserver.opaquetoken.* is set). Internally wraps the introspector in an audience-checking delegate so the MCP-mandated aud validation still runs.

To replace both with a custom strategy — for a hypothetical future token format, or to plug in a mock for testing — register a @Primary bean:

@Bean
@Primary
McpTokenStrategy customStrategy() {
    return rs -> rs.jwt(jwt -> jwt.decoder(myCustomDecoder()));
}

The same instance is applied to both the metadata and MCP chains, so there's exactly one place to swap validation behavior.

Customizing the metadata document

The RFC 9728 metadata document served at /.well-known/oauth-protected-resource is assembled by a list of McpMetadataCustomizer beans. Mocapi ships five baseline customizers, each responsible for one facet:

Customizer Field(s) it sets Source
ResourceMetadataCustomizer resource mocapi.oauth2.resource, or the single jwt.audiences entry when unset
AuthorizationServersMetadataCustomizer authorization_servers mocapi.oauth2.authorization-servers, or jwt.issuer-uri fallback
ScopesSupportedMetadataCustomizer scopes_supported mocapi.oauth2.scopes
ResourceNameMetadataCustomizer resource_name Implementation.title(), falling back to Implementation.name() (i.e. mocapi.server-title / mocapi.server-name)
ClaimsMetadataCustomizer resource_documentation, resource_policy_uri, resource_tos_uri the matching mocapi.oauth2.* properties; only emits a claim when the property is set

Adding a custom claim

Register a @Bean McpMetadataCustomizer. Default @Order runs it after the five baseline customizers, so you see (and can overwrite) whatever they set.

@Bean
McpMetadataCustomizer tlsBoundTokenAdvertisement() {
    return builder -> builder.tlsClientCertificateBoundAccessTokens(true);
}

Overriding a baseline facet

Two approaches, pick whichever fits:

  1. Register a later-@Order McpMetadataCustomizer that mutates the field you want to change. Mocapi's baselines use @Order(HIGHEST_PRECEDENCE), so any customizer with a later order (including the default) runs after them and wins:
@Bean
@Order(0)
McpMetadataCustomizer overrideResourceName() {
    return builder -> builder.resourceName("My Custom Name");
}
  1. Register a @Primary replacement for the specific baseline bean type (e.g. @Primary ResourceNameMetadataCustomizer). The autoconfig uses @ConditionalOnMissingBean on each baseline, so your bean replaces it entirely and mocapi's default never registers:
@Bean
@Primary
ResourceNameMetadataCustomizer myResourceName() {
    return new ResourceNameMetadataCustomizer(/* ... */) {
        @Override
        public void customize(OAuth2ProtectedResourceMetadata.Builder builder) {
            builder.resourceName("My Custom Name");
        }
    };
}

Use this when you want to take over a facet completely, including its property-reading constructor logic.

Opaque tokens

Some IdPs issue opaque (non-JWT) access tokens and validate them via RFC 7662 introspection. Mocapi auto-detects the mode from which Spring Boot resource-server properties are configured: set spring.security.oauth2.resourceserver.opaquetoken.* instead of jwt.*.

spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: https://idp.example.com/introspect
          client-id: mcp-client
          client-secret: ${INTROSPECTION_SECRET}
        jwt:
          audiences:
            - mcp.example.com                          # still required — enforced via introspection response

The jwt.audiences property is reused in opaque mode — mocapi wraps Spring's OpaqueTokenIntrospector to enforce the aud claim on the introspection response, since Spring's opaque path does not include an audience validator out of the box. The MCP spec still requires audience checking, so this wrapper is not optional.

JWT and opaque modes are mutually exclusive; configure one or the other. If both are somehow configured, JWT wins (matching Spring Boot's own precedence).

Per-handler authorization

mocapi-oauth2 validates tokens on the way in. Gating individual handlers on the resulting Authentication is a second concern, covered by the mocapi-spring-security-guards module.

Which layer do I want? The two are complementary, and they behave differently on denial:

mocapi.oauth2.required-scopes @RequiresScope on a handler
Granularity The whole MCP endpoint One tool / prompt / resource
Enforced by Spring Security filter chain, before dispatch Guard SPI, inside dispatch
Denial shape 403 + insufficient_scope challenge Handler hidden from */list; -32010 Forbidden on call
Tells the client what to ask for Yes — that's the point No, deliberately
Works on stdio No (HTTP-only) Yes

Use required-scopes for "you need scope X to use this server at all," where naming the missing scope is helpful and safe. Use @RequiresScope for per-tool rules, where mocapi's visibility ≡ invocation model means an unentitled caller never sees the tool — so there's nothing to step up to, and naming the scope would leak the existence of a hidden handler. The filter chain also physically cannot do per-tool rules: every tool arrives at the same /mcp URL, and the filter runs before mocapi knows which one is being called. See ADR-0029.

Add the guards module alongside mocapi-oauth2:

<dependency>
    <groupId>com.callibrity.mocapi</groupId>
    <artifactId>mocapi-spring-security-guards</artifactId>
    <version>${mocapi.version}</version>
</dependency>

Then annotate handler methods:

@McpTool(name = "tenant_admin_op")
@RequiresScope("admin:write")           // AND — all listed scopes required
@RequiresRole({"TENANT_ADMIN", "OPS"})  // OR — any listed role grants access
public void tenantAdminOp(...) { ... }

@RequiresScope values match granted authorities with the SCOPE_ prefix Spring Security's JWT / opaque-token converters produce (so admin:write matches SCOPE_admin:write). @RequiresRole accepts bare (ADMIN) or prefixed (ROLE_ADMIN) values; both normalize to the same granted authority. Both annotations may coexist on the same method — the Guard SPI's AND evaluation means every attached guard must allow.

Denied calls do not reach the handler: tools/list (and the matching prompts/list, resources/list, resources/templates/list) hides the handler entirely, and tools/call returns JSON-RPC -32010 with Forbidden: <reason> where the reason comes from the first denying guard (unauthenticated, missing scope(s): ..., or insufficient role). Deny reasons are only returned at call time — list time simply omits the handler so the decision doesn't leak.

Unlike Spring Security's @PreAuthorize, guards gate list operations as well as call operations — clients doing tools/list never see handlers they aren't entitled to invoke. And guard denials surface as the protocol-right -32010 Forbidden shape (ADR-0023) instead of the generic -32603 Internal error an AOP-thrown AccessDeniedException would produce. See docs/guards.md for the underlying Guard SPI, and the mocapi-spring-security-guards module for the annotation sources if you want to write your own Guard implementation.

Stdio transport

OAuth2 is transport-bearer-token-specific and applies only to the Streamable HTTP transport. Stdio (subprocess-launched) MCP servers authenticate the subprocess via its launch context; there are no bearer tokens to validate. mocapi-oauth2 depends on mocapi-streamable-http-spring-boot-starter and is not compatible with stdio-only deployments.

What's not supported

Deliberately declined, with rationale (reopen at the linked ADR if your threat model needs one):

  • DPoP (RFC 9449) and mTLS token binding — see ADR-0022.
  • Signed metadata (application/resource-metadata+jwt) — Spring serves the RFC 9728 document as JSON only. ADR-0022.
  • Per-tool insufficient_scope step-up — declined because it conflicts with mocapi's visibility ≡ invocation model (a scope-gated tool is hidden, not challenged); ADR-0029. Resource-level insufficient_scope is supported — see Requiring a scope above.

Current limitation, with a workaround:

  • Multiple issuer-uris (federation). mocapi.oauth2.authorization-servers accepts a list and advertises them all in the metadata document, but Spring Boot's auto-wired JwtDecoder validates against a single issuer. To accept tokens from more than one issuer today, register your own JwtDecoder bean (e.g. a JwtIssuerAuthenticationManagerResolver-backed decoder) — mocapi wires whatever JwtDecoder is present onto both filter chains.

The forward view for these lives in the roadmap; nothing here is a dated commitment.