Skip to main content

Securing an MCP Server with Spring Security 7: OAuth2, Scopes per Tool, and Audit Logs

Protect a Spring AI MCP server with a real OAuth2 resource server: JWT validation, one scope per tool via @PreAuthorize, unauthenticated tool discovery rejected outright, and an MDC audit log that still fires on denied calls. Includes two real findings: a renamed Spring Boot starter and a duplicated error message.

The MCP server article in this series exposed an order-lookup service as MCP tools with nothing standing between a caller and the data — any client that can reach the port can list every tool and call every one of them. That’s fine for a laptop demo. It’s not fine for anything a model can reach over a network with real orders behind it. This article puts a real OAuth2 resource server in front of the same shape of service: JWT bearer tokens, one scope per tool, no anonymous tool discovery, and an audit log that still runs the moment a call gets rejected — not only when one succeeds. Everything below was actually run against a real, running Spring Security filter chain and a real signed JWT, not asserted from the reference docs. Two of the findings — a renamed starter artifact and a message that gets duplicated in a denied tool’s error text — only turned up because of that.
Versions. Spring Boot 4.1.1 and Spring AI 2.0.1, on Java 25 (LTS) — the same baseline as the rest of this series. Spring Security is 7.1.1, managed directly by the Boot 4.1.1 parent (checked in its POM, not assumed from the artifact’s own release notes). The JWT library underneath is nimbus-jose-jwt 10.9.1, pulled in transitively by spring-security-oauth2-jose. This article assumes tokens come from a real Authorization Server in production — see running your own or validating against one, both already on this site.

A resource server that trusts nobody by default

Adding spring-boot-starter-oauth2-resource-server to a Spring Boot application doesn’t protect anything by itself — it makes JWT validation available. What actually locks the door is a SecurityFilterChain that says every request needs to be authenticated, and a JwtDecoder bean telling Spring Security how to check a token’s signature:
@Bean
public SecurityFilterChain mcpSecurityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    return http.build();
}
Source: McpSecurityConfig.java. A real resource server points its JwtDecoder at an Authorization Server’s JWKS endpoint with NimbusJwtDecoder.withJwkSetUri(...), fetching real public keys over the network. This article’s companion repo can’t assume you have one of those running, so it mints its own: one RSA keypair, generated once per JVM, that both signs every token the repo’s tests issue and validates every token the running server accepts.
DemoJwtIssuer one RSA keypair per JVM private key signs tokens issueToken() mints public key NimbusJwtDecoder.withPublicKey(…) Every request’s Authorization header
The dashed arrow is the part that only exists in this repository. A production resource server never holds the private key at all — only the Authorization Server does, and the public key arrives over a JWKS URI the resource server polls, not from an in-process object. Swapping this module from demo to production is a one-line change to the JwtDecoder bean, not a redesign:
/**
 * A real resource server points this at an issuer: NimbusJwtDecoder.withJwkSetUri(...)
 * or .withIssuerLocation(...), fetching a real JWKS over the network. This module
 * validates against the one RSA keypair DemoJwtIssuer generated for this JVM instead.
 */
@Bean
public JwtDecoder jwtDecoder(DemoJwtIssuer issuer) {
    return NimbusJwtDecoder.withPublicKey(issuer.publicKey()).build();
}
Source: DemoJwtIssuer.java.

Going deeper on this section

No anonymous tool discovery

The MCP server article showed a client calling tools/list before ever calling a tool — that’s how MCP Inspector and Claude Desktop both work, and it’s exactly the request this article’s SecurityFilterChain refuses to let through unauthenticated. authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) covers the whole application, including the handshake:
client.initialize() with no bearer token threw:
  java.lang.RuntimeException: Client failed to initialize by explicit API call

  caused by [0]: io.modelcontextprotocol.client.transport.McpHttpClientTransportAuthorizationException: Authorization error when sending message
Output: 01-no-token-discovery-rejected.txt. There’s no partial handshake to inspect here — the HTTP request carrying the MCP initialize call never reaches the DispatcherServlet, because Spring Security’s filter chain runs first and returns 401 on its own. A client with no token, or a token signed by the wrong key, learns nothing about this server: not its name, not its tool count, not a single tool’s description.
Why this matters more for MCP than for a typical REST API. A tool’s description is itself information — “refund_order: refund an order by ID” tells an attacker a refund endpoint exists before they’ve proven anything. Rejecting discovery, not just execution, is a small config decision with an outsized effect on what an unauthenticated caller can learn about a system just by asking it politely.

Going deeper on this section

One scope per tool, with the annotation you already know

Nothing about mapping a scope to an MCP tool needs an MCP-specific mechanism. @McpTool marks a method as a tool; @PreAuthorize is the ordinary Spring Security method-security annotation, and the two stack on the same method exactly the way @PreAuthorize stacks on any other bean method:
@McpTool(name = "lookup_order",
        description = "Look up a single order by its ID and return its customer, status, items, and total.")
@PreAuthorize("hasAuthority('SCOPE_orders:read')")
public Order lookupOrder(String orderId) { ... }

@McpTool(name = "refund_order",
        description = "Refund an order by ID, marking it REFUNDED.")
@PreAuthorize("hasAuthority('SCOPE_orders:write')")
public Order refundOrder(String orderId) { ... }
Source: SecureOrderTools.java. SCOPE_orders:read is not a made-up string: Spring Security’s default JwtGrantedAuthoritiesConverter reads a token’s scope (or scp) claim, splits it on spaces, and prefixes each entry with SCOPE_ to produce a granted authority — confirmed directly in the class’s bytecode, not assumed from its Javadoc. A token minted with {"scope": "orders:read orders:write"} becomes two authorities, and hasAuthority('SCOPE_orders:read') checks for one of them. Calling both tools with a token that only carries orders:read:
lookup_order (scope orders:read present):
  isError = false
  text = {"id":"ORD-1001","customer":"Priya Nair","status":"SHIPPED","items":["Mechanical keyboard","USB-C cable"],"total":4899.00}

refund_order (scope orders:write absent):
  isError = true
  text = Access Denied
Access Denied
Output: 02-read-scope-lookup-succeeds.txt. A write-scoped token flips the outcome — refund_order succeeds, lookup_order is denied:
refund_order (scope orders:write present):
  isError = false
  text = {"id":"ORD-1002","customer":"Rohan Mehta","status":"REFUNDED","items":["27\" monitor"],"total":18999.00}

lookup_order (scope orders:read absent):
  isError = true
  text = Access Denied
Access Denied
Output: 03-write-scope-refund-succeeds.txt. And a token with both scopes does both:
lookup_order: isError=false text={"id":"ORD-1003","customer":"Ankita Rao","status":"DELIVERED","items":["Laptop stand","Wireless mouse","USB hub"],"total":3450.00}
refund_order: isError=false text={"id":"ORD-1003","customer":"Ankita Rao","status":"REFUNDED","items":["Laptop stand","Wireless mouse","USB hub"],"total":3450.00}
Output: 04-both-scopes-both-succeed.txt. This only works at all because of something worth stating plainly: the bean the MCP server autoconfiguration invokes through reflection is the Spring-proxied bean from the application context, the same proxy method security always relies on for any other @Service. That isn’t guaranteed by MCP’s design — it would break silently if the autoconfiguration ever grabbed a raw, unproxied instance instead — which is exactly why this article’s tests call the real running server over real HTTP instead of asserting it from reading the source.
Notice the duplicated text. Both denial responses above read Access Denied\nAccess Denied, not Access Denied once. That’s not a copy-paste mistake in this article — it’s what the server actually returns, and the next section explains exactly why.

Going deeper on this section

What a denied tool call actually returns, and why the message repeats

The MCP server article established that a failing tool call comes back as HTTP 200 with isError:true, never an HTTP error status. A @PreAuthorize denial follows the exact same shape — it’s a tool-level failure, not a protocol-level or transport-level one, because the exception is thrown from inside the same reflective method invocation any other tool exception would come from. What’s specific to this exception is the doubled text. AbstractSyncMcpToolMethodCallback.createSyncErrorResult(Exception), inside spring-ai-mcp-annotations-2.0.1.jar (not part of this article’s own companion repo, but decompiled from the real jar this repo depends on, not read from a changelog), builds the tool’s error content like this:
// Roughly what the bytecode does:
String text = exception.getMessage() + System.lineSeparator() + rootCause(exception).getMessage();
return CallToolResult.builder().isError(true).addTextContent(text).build();
The method assumes a caught exception usually wraps a more specific root cause — a service exception wrapping a database error, say — and prints both messages so nothing gets lost. @PreAuthorize‘s denial throws AuthorizationDeniedException directly, with no wrapped cause: exception and rootCause(exception) resolve to the same object, so its one message, "Access Denied", gets concatenated with itself. Nothing is wrong; the same formatting logic that would usefully show two different messages for a different kind of failure just has nothing different to show here.
Going deeper: AuthorizationDeniedException, not AccessDeniedException

Code written to catch the older, more familiar org.springframework.security.access.AccessDeniedException around a method-security check still works — Spring Security 7’s @PreAuthorize interceptor actually throws org.springframework.security.authorization.AuthorizationDeniedException, a subclass, confirmed by disassembling its class file. This module’s own audit aspect (next section) deliberately catches on the parent type for exactly that reason: matching only the subclass would silently stop classifying denials correctly the moment a differently-shaped authorization failure came through the older type.

Going deeper on this section

Auditing every call, including the ones that get denied

A tool-call audit log that only records successes is worse than no audit log at all — the calls worth investigating are disproportionately the denied ones. Logging every @McpTool invocation through MDC is one @Aspect:
@Around("@annotation(org.springframework.ai.mcp.annotation.McpTool)")
public Object audit(ProceedingJoinPoint joinPoint) throws Throwable {
    String tool = mcpToolName(joinPoint);
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    MDC.put("mcp.tool", tool);
    MDC.put("mcp.subject", authentication.getName());
    MDC.put("mcp.scopes", authentication.getAuthorities().toString());
    try {
        Object result = joinPoint.proceed();
        MDC.put("mcp.outcome", "success");
        log.info("mcp tool call");
        return result;
    }
    catch (Throwable t) {
        MDC.put("mcp.outcome", t instanceof AccessDeniedException ? "denied" : "error");
        MDC.put("mcp.exception", t.getClass().getSimpleName());
        log.warn("mcp tool call failed");
        throw t;
    }
    finally {
        MDC.clear();
    }
}
Source: ToolAuditAspect.java. Whether this actually logs a denied call at all turns out to depend on one number most Spring Security users never look at.
100 PRE_FILTER 150 ToolAuditAspect @Order(150) 200 PRE_AUTHORIZE lower order = runs more outermost — the aspect wraps the @PreAuthorize check, not the other way round
Spring’s AOP advisor ordering runs lower numbers outermost. Printing every AuthorizationInterceptorsOrder constant’s getOrder() against this exact dependency version shows PRE_AUTHORIZE = 200; an @Aspect with no explicit @Order gets no guarantee of running outside that interceptor, and a denied call could end up never reaching this aspect’s catch block at all — the audit log would be silently blind to exactly the calls it exists to catch. Pinning ToolAuditAspect to @Order(150), between PRE_FILTER (100) and PRE_AUTHORIZE (200), places it outside the authorization check on purpose. A real Logback appender attached to the real MCP_AUDIT logger, reading back the real MDC contents of one successful call and one denied one:
event 1: mcp tool call {mcp.outcome=success, mcp.scopes=[SCOPE_orders:read, FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-09-23T15:12:38.937894021Z]], mcp.subject=audit-test-user, mcp.tool=lookup_order}

event 2: mcp tool call failed {mcp.exception=AuthorizationDeniedException, mcp.outcome=denied, mcp.scopes=[FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-09-23T15:12:38.949708690Z], SCOPE_orders:read], mcp.subject=audit-test-user, mcp.tool=refund_order}
Output: 05-audit-log-both-outcomes.txt. Both events logged, the denial correctly tagged mcp.outcome=denied with the real exception class name attached — and mcp.tool reads lookup_order, the MCP-facing tool name, not the Java method name lookupOrder, because the aspect reads it off the @McpTool annotation itself rather than off the join point’s method signature.
Going deeper: FactorGrantedAuthority and Spring Security’s authentication factors

Neither authority list above was written by hand — both came straight out of a real Authentication object, and both carry a FactorGrantedAuthority [authority=FACTOR_BEARER, ...] entry alongside the expected SCOPE_orders:read. Spring Security’s JWT bearer authentication now automatically tags a successful authentication with which authentication factor produced it — part of Spring Security’s step-up/multi-factor authorization support, where a @PreAuthorize expression can require not just a scope but a specific factor and a recency window on it. This article doesn’t use that feature, but any code inspecting getAuthorities() directly (rather than going through hasAuthority(...)) should expect to see it mixed in.

Going deeper on this section

A starter that quietly stopped existing

Wiring @Aspect classes into a fresh Spring Boot 4.1 project by memory reaches for spring-boot-starter-aop, the same artifact that has worked since Boot 1.x. It doesn’t resolve:
[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing.
Checking Maven Central’s metadata for the artifact directly shows why: the last version published is 4.0.0-M2 — a milestone, not even a GA release. Spring Boot 4.0 renamed it to spring-boot-starter-aspectj, confirmed by grepping spring-boot-dependencies-4.1.1.pom for every AspectJ-related coordinate it manages:
    <aspectj.version>1.9.25.1</aspectj.version>
        <artifactId>spring-boot-starter-aspectj</artifactId>
        <version>4.1.1</version>
Full transcript, including the exact commands run: 06-starter-aop-renamed.txt. Nothing else about using it changed — the dependency still exists, still pulls in the same AspectJ weaver jar, still makes @Aspect/@Pointcut syntax parseable under ordinary proxy-based Spring AOP with no actual weaving involved. Only the coordinate moved.
If you’re upgrading an older project to Boot 4.x and it declares spring-boot-starter-aop explicitly (rather than getting AOP support transitively from something like Spring Data or Spring Security’s method security, which don’t need this starter at all), this is a silent break: the dependency simply won’t resolve against the 4.1.1 BOM, with an error that says nothing about a rename.

Going deeper on this section

  • Companion repo: pom.xml, with the full comment explaining the swap

Should you protect an MCP server this way?

Everything in this article covers authentication and coarse-grained authorization — who can call this server at all, and which named scope a given call needs. It does not cover a harder problem this series has not addressed yet: a model deciding, on its own, which tool to call and with what arguments, entirely within the scopes it was legitimately granted. A token scoped to orders:write can refund any order it can name, not just ones some higher-level business rule would allow; OAuth2 scopes are a coarse, static permission boundary, not a row-level or business-rule authorization system.
Should you even ship a write-capable tool at all? A refund_order tool, once registered, is one confidently-worded prompt away from being called. Scoping it behind orders:write stops an under-scoped caller; it does nothing to stop a properly-scoped caller from refunding the wrong order because a model reasoned its way there. For anything genuinely destructive, a human-in-the-loop confirmation step above the tool layer is worth more than another scope.

Every Spring AI article on this site

ArticleCovers
Spring AI 2.0 in 10 Minutes: ChatClient on Spring Boot 4.1the beginner-level ChatClient build this article’s versions and setup follow
Build an MCP Server with Spring AI 2.0the unsecured order-lookup server this article puts a resource server in front of
Spring AI MCP Client: Calling External MCP Servers from ChatClientthe client side of MCP — ChatClient calling tools from real external servers over stdio
Spring AI 1.x to 2.0: The Migration Guidewhat breaks, and what breaks silently, upgrading an existing 1.x application
Production-Grade RAG with Spring AIchunking, ingestion, retrieval, reranking, and a faithfulness check against pgvector
Spring AI RAG in Java: Complete Code Tourthe same RAG project, file by file
Vector Embeddings and Semantic Search in Pure Javathe mechanics of embeddings and cosine similarity, without Spring AI

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.