To secure a Spring Boot MCP server under the 2026-07-28 spec, treat it as an OAuth 2.1 resource server: keep the endpoint stateless, validate the JWT signature, issuer and audience, authorize each tool by scope, and demand step-up authentication for payment tools. On the login-server side, Client ID Metadata Documents (CIMD) replace Dynamic Client Registration.
I previously covered exposing a Java backend as an MCP server with Spring AI, which dealt with getting tools in front of an agent. Here the question is different: what changes once that agent can reach a payment API, and what the 2026-07-28 revision of the protocol means for whoever carries the pager for the server.
The authorization half of this is not news. The spec has framed MCP servers as OAuth resource servers since the 2025-06 revision. What the new revision changes is operational: it removes the session handshake and the open-registration fallback, the two things that made a remote MCP server painful to run behind a normal load balancer and a normal identity provider. If you have built PSD2 APIs, a lot carries over directly. Strong customer authentication step-up becomes an acr/auth_time challenge (RFC 9470). TPP consent scopes become tool scopes. The "which TPP is this?" question becomes the CIMD client ID. The mapping is almost one-to-one, and I use it below.
The plain-language version
If OAuth and MCP vocabulary is new to you, this section is the short glossary. Otherwise skip to the architecture.
MCP (Model Context Protocol) lets an AI assistant ask another system "what can you do?" and get back a list of tools, for example "look up a payment" or "create a refund", which it can then call.
OAuth lets an application act for a user without ever seeing the user's password, the mechanism behind every "Sign in with Google" button. It involves a client (here, the AI application), an authorization server (the login system that hands out access tokens), and a resource server (the system that holds the data and inspects those tokens). Your MCP server is the resource server.
An access token is usually a JWT: a signed blob of claims saying who the user is, which scopes (named permissions such as payments:read) were granted, and which service the token was issued for. That last claim is the audience, and it is the one people forget. A token issued for the billing service must bounce off the payments service.
Two problems drove the 2026-07-28 changes.
Memory. Older remote MCP servers started a session: the client sent an initialize call, received a session ID, and every follow-up request had to land on the replica that remembered it. In practice that meant sticky sessions on the load balancer, and a user whose pod got rescheduled lost their conversation. The new revision drops the initialize handshake and the session ID, so each request is self-contained and any replica can serve it.
Introductions. Before an authorization server can show a consent screen, it has to know who the app is. The old fallback was Dynamic Client Registration (DCR, RFC 7591), where any app registers itself unattended. Across thousands of AI apps and one shared login server, that produces thousands of records nobody can vouch for. The revision deprecates DCR in favour of Client ID Metadata Documents (CIMD): the app publishes a JSON file on its own domain and uses that file's URL as its client ID. The login server fetches it, reads the name and redirect addresses, and applies its own trust policy. No record exists on the login server until a human or a policy says yes.
What the changelog confirms, and what it leaves open
I read the official 2026-07-28 changelog on modelcontextprotocol.io and compared it with the project's release post. These points are firm:
- The initialize handshake and session ID are gone from the core, which is the basis for the stateless transport.
- DCR (RFC 7591) is deprecated in favour of CIMD (the changelog tracks this as PR #2858). It keeps working for backward compatibility and is slated for removal in a later version.
- Issuer validation per RFC 9207 is added, so a client can tell whether an authorization response really came from the server it called.
Two things are not firm. The changelog gives no removal date for DCR, so I plan around "deprecated, not removed" rather than any calendar. And a few secondary write-ups list a registration priority order (pre-registered credentials, then CIMD, then DCR, then ask the user); I have not seen that order stated in the spec text, so I do not rely on it.
On Spring AI, my working position is this. Spring Security gives you everything in the OAuth half for free: resource-server JWT validation, SCOPE_ authorities, method security. Everything MCP-specific that I describe below as custom (protected resource metadata, audience binding, step-up guard, CIMD resolution) is code I wrote and run myself, because I did not find a Spring AI starter that provides it for me. If a newer release of the starters covers one of them, delete mine; the Spring AI changelog for your exact version settles that in a few minutes. For a broader view of what Java teams get when building agents on top of this, see my notes on Spring AI's new Agents project.
Architecture
Four moves. The client calls the MCP endpoint with no token and gets a 401 pointing at the server's protected resource metadata (RFC 9728), a JSON document naming the authorization server. The client then goes to that authorization server and presents its CIMD URL as its client ID. It returns with a token. Finally it calls whichever replica the load balancer picks, and that replica validates the token locally. Nothing on any instance remembers the client, which is what keeps scaling out dull, the quality I want most from infrastructure.
Step 1: a stateless endpoint behind a plain load balancer
At AirAsia we served around 130 million requests a day, and the rule that paid off most was refusing instance-local state. I have watched sticky sessions mask a capacity problem: one node runs hot while its neighbours idle, the dashboards average it out, and nobody notices until that node is replaced during a deploy and its users all get logged out at once. A stateless MCP endpoint lets you keep the same rule.
The security configuration is where statelessness shows up. No session, no CSRF cookie ritual, only bearer tokens:
@Configuration
@EnableMethodSecurity
class McpSecurityConfig {
@Bean
SecurityFilterChain mcp(HttpSecurity http,
McpAuthEntryPoint entryPoint) throws Exception {
http
.securityMatcher("/mcp/**", "/.well-known/oauth-protected-resource")
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()) // bearer-only, no cookies
.authorizeHttpRequests(a -> a
.requestMatchers("/.well-known/oauth-protected-resource").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o
.jwt(Customizer.withDefaults())
.authenticationEntryPoint(entryPoint));
return http.build();
}
}
Turning CSRF off is defensible only because this path takes nothing but an Authorization header and never reads cookies. The day someone adds cookie auth to the same prefix, switch it back on.
Whether the MCP transport itself is stateless is a separate question from whether your security chain is. Spring AI has had a stateless Streamable HTTP server mode for a while, and that is where I would start. I could not match it against the final 2026-07-28 wire format from the release notes alone, so I test instead of trusting: run two replicas, alternate requests between them in an integration test, and treat any failure as hidden state. It is a ten-line test and it has caught session leaks for me more than once.
Step 2: publish protected resource metadata
The client needs to learn where to log in. RFC 9728 defines a well-known document for exactly that. This is custom code, and it is small:
@RestController
class ProtectedResourceMetadataController {
private final McpSecurityProperties props;
ProtectedResourceMetadataController(McpSecurityProperties props) {
this.props = props;
}
@GetMapping(value = "/.well-known/oauth-protected-resource",
produces = MediaType.APPLICATION_JSON_VALUE)
Map<String, Object> metadata() {
return Map.of(
"resource", props.resourceUri(), // e.g. https://mcp.example.com/mcp
"authorization_servers", List.of(props.issuer()),
"scopes_supported", List.of("payments:read", "payments:write", "mandates:read"),
"bearer_methods_supported", List.of("header"));
}
}
resource does double duty. It is also the identifier your tokens must carry as their audience, which the next step enforces.
Step 3: JWT validation with audience binding
This is the step where I have seen real damage. A resource server that checks signature and issuer but not audience will happily accept a token that was minted for a different service in the same realm. In open banking we called it a confused-deputy bug and it failed pen-tests. The typical way it arrives: a team reuses one realm for the internal admin API and the new agent-facing API, the agent's token is scoped fine, and nobody notices that the admin API never asked who the token was for. With MCP the exposure is wider, because the premise is that many different agents hold tokens.
Spring Security verifies signature and expiry by default. Audience binding takes a few lines of your own, expressed as a proper OAuth2TokenValidator so failures come back as standard invalid_token errors:
@Bean
JwtDecoder jwtDecoder(McpSecurityProperties props) {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withIssuerLocation(props.issuer())
.build();
OAuth2TokenValidator<Jwt> issuer =
JwtValidators.createDefaultWithIssuer(props.issuer());
OAuth2TokenValidator<Jwt> audience = token ->
token.getAudience().contains(props.resourceUri())
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(new OAuth2Error(
"invalid_token", "Token audience does not include this MCP server", null));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(issuer, audience));
return decoder;
}
Two details matter. Compare against the exact resource URI, never a prefix match, or a hostile https://mcp.example.com.evil.net slips through. And the decoder downloads the issuer's signing keys and caches them, so a request does not trigger a network call and replicas share nothing. The flip side is key rotation: when the identity provider rolls its keys, a decoder holding a stale cache rejects good tokens until it refetches, so alert on a burst of signature failures right after a rotation rather than assuming the attack is real.
Step 4: where CIMD fits, and who does the work
This confuses people, so to be explicit: your MCP server never touches CIMD documents. The authorization server does. The client hands the authorization server a client ID that is an HTTPS URL, such as https://agent.example.org/client.json. The authorization server downloads it, checks that the client_id field inside equals the URL it came from, validates the redirect URIs, and shows the user a consent screen carrying the app's name.
For a Java team the work divides like this:
- If you operate your own authorization server on Spring Authorization Server, expect to write a small
RegisteredClientRepositorythat turns a URL-shaped client ID into a client by fetching and validating the document. I have not seen URL-form client IDs supported out of the box, so I plan on writing it. - If you use Keycloak, Entra, Auth0 or Okta, ask the vendor for their CIMD date in writing. Some will trail the spec by months, which is why you keep DCR on, behind rate limits, until they ship.
- Whoever does the fetching, treat that fetch as hostile. The URL comes from a stranger, so block private address ranges, cap the body size, set short timeouts and refuse redirects. The SSRF mitigation post covers this class of bug, and CIMD is a textbook instance: a login server that downloads arbitrary URLs for people it has never met.
This is the validation I would put in front of that fetch:
record ClientMetadata(String clientId, String clientName, List<String> redirectUris) {}
class CimdResolver {
private final HttpClient http = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(2))
.build();
ClientMetadata resolve(URI clientIdUrl) throws Exception {
if (!"https".equals(clientIdUrl.getScheme()) || clientIdUrl.getPath().isEmpty()) {
throw new IllegalArgumentException("client_id must be an https URL with a path");
}
// Resolve and reject loopback, link-local and private ranges before connecting.
SsrfGuard.assertPublicAddress(clientIdUrl.getHost());
var request = HttpRequest.newBuilder(clientIdUrl)
.timeout(Duration.ofSeconds(3)).GET().build();
var response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200 || response.body().length > 16_384) {
throw new IllegalArgumentException("Metadata fetch rejected");
}
ClientMetadata meta = mapper.readValue(response.body(), ClientMetadata.class);
if (!meta.clientId().equals(clientIdUrl.toString())) {
throw new IllegalArgumentException("client_id in document does not match its URL");
}
return meta;
}
}
The client_id equality check carries the whole design. Without it anyone can host a document claiming to be somebody else's app.
On the benefits over DCR: a DCR identity is a random string your server minted, which tells you nothing. A CIMD identity is a domain somebody controls, so you can rate-limit it, block it and put it in a report. For compliance work this is the real gain. I can finally answer an auditor's "who is this client?" with a name and a domain, not "whoever hit the registration endpoint".
Step 5: scope-based tool authorization
Authentication says who; authorization says what. In a tool protocol the natural unit is the tool. Enforce at the method that implements the tool, not at the HTTP path, because MCP routes every call through one endpoint and a path rule cannot distinguish get_payment from create_refund.
@Service
class PaymentTools {
@Tool(description = "Look up a payment by its id")
@PreAuthorize("hasAuthority('SCOPE_payments:read')")
PaymentView getPayment(String paymentId) {
return payments.find(paymentId);
}
@Tool(description = "Create a refund for a settled payment")
@PreAuthorize("hasAuthority('SCOPE_payments:write')")
RefundReceipt createRefund(String paymentId, BigDecimal amount) {
stepUp.require(StepUpLevel.STRONG); // see next section
return refunds.create(paymentId, amount);
}
}
Spring Security converts the token's scope claim into SCOPE_ authorities by default, so the annotation works without extra mapping; no string comparisons in the tool body. The trap is that @Tool discovery and Spring's method-security proxy must apply to the same bean. If the proxy is missing, @PreAuthorize is silently ignored and every call is allowed. So write a test that sends a token without payments:write and expects a refusal. That one test is the only thing standing between you and an open refund tool.
A second layer is easy to miss: the tool list itself. Filter it per caller, so an agent with read-only scope never even sees createRefund. A manipulated model has less to attempt, and agent behaviour gets more predictable.
Step 6: step-up authorization for high-risk tools
A scope granted at login is a blunt instrument. The user said yes to "payments" an hour ago in a chat about something else. Should an instruction injected through a web page be able to move money on the strength of that old yes? No.
Step-up authorization means the server asks for fresher or stronger proof before dangerous actions. The mechanism is a challenge: the server responds with a WWW-Authenticate header, and the client goes back to the authorization server for a token with the extra scope or a stronger authentication level. For "you lack the permission" the code is insufficient_scope, with the scope named in the header. For "log in again, more strongly", RFC 9470 defines the insufficient_user_authentication error built on the acr (authentication context class) and auth_time claims.
In PSD2 terms, this is SCA dynamic linking applied to an agent: the bank re-checks the customer at the moment of the risky act, not at session start. The mandate flows I built for recurring payments (see the Commercial VRP post) work the same way. An agent calling a payment tool is just a new caller of an old rule.
This part is custom code; I have not seen Spring AI ship a step-up primitive, so here is the guard I use:
@Component
class StepUpGuard {
private static final Duration MAX_AGE = Duration.ofMinutes(5);
void require(StepUpLevel level) {
Jwt jwt = (Jwt) SecurityContextHolder.getContext()
.getAuthentication().getPrincipal();
boolean strongEnough = level.acceptedAcr().contains(jwt.getClaimAsString("acr"));
Instant authTime = Instant.ofEpochSecond(
((Number) jwt.getClaim("auth_time")).longValue());
boolean fresh = authTime.isAfter(Instant.now().minus(MAX_AGE));
if (!strongEnough || !fresh) {
throw new StepUpRequiredException(level, MAX_AGE);
}
}
}
@RestControllerAdvice
class StepUpAdvice {
@ExceptionHandler(StepUpRequiredException.class)
ResponseEntity<Void> challenge(StepUpRequiredException ex) {
String header = "Bearer error=\"insufficient_user_authentication\","
+ " error_description=\"A stronger or more recent login is required\","
+ " acr_values=\"" + ex.level().primaryAcr() + "\","
+ " max_age=\"" + ex.maxAge().toSeconds() + "\","
+ " resource_metadata=\"" + props.metadataUrl() + "\"";
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.header(HttpHeaders.WWW_AUTHENTICATE, header).build();
}
}
Use whichever error code your authorization server and chosen MCP client both understand, and test the full round trip, since client behaviour on challenges is the least uniform part of this ecosystem. Keep the five-minute window in configuration so risk and compliance can change it without a deploy.
One rule I do not bend: the model never decides whether step-up happened. The guard reads the verified token and nothing the agent says.
Step 7: audit logging you can hand to a regulator
Each tool call by an agent is a decision taken on someone's behalf, and in a regulated setting somebody will later ask you to reconstruct it. DORA-style expectations boil down to: which user, which client, which tool, which arguments, what happened. The evidence side is in the DORA and NIS2 policy-as-code post.
An aspect over the tool methods keeps this out of business code:
@Aspect
@Component
class ToolAuditAspect {
private static final Logger audit = LoggerFactory.getLogger("mcp.audit");
@Around("@annotation(org.springframework.ai.tool.annotation.Tool)")
Object record(ProceedingJoinPoint pjp) throws Throwable {
Jwt jwt = (Jwt) SecurityContextHolder.getContext()
.getAuthentication().getPrincipal();
String tool = pjp.getSignature().getName();
long started = System.nanoTime();
String outcome = "ALLOWED";
try {
return pjp.proceed();
} catch (AccessDeniedException | StepUpRequiredException e) {
outcome = "DENIED";
throw e;
} catch (Exception e) {
outcome = "FAILED";
throw e;
} finally {
audit.info("tool={} sub={} client={} acr={} outcome={} ms={} trace={}",
tool, jwt.getSubject(), jwt.getClaimAsString("client_id"),
jwt.getClaimAsString("acr"), outcome,
(System.nanoTime() - started) / 1_000_000,
Span.current().getSpanContext().getTraceId());
}
}
}
Log the tool name and identifiers, but hash or mask arguments that contain account numbers. The trace ID links the audit line to the distributed trace, which is how a strange agent run actually gets debugged (see distributed tracing with OpenTelemetry). Ship the audit logger to an append-only store apart from application logs. For metrics and alerting on agent behaviour, see observability and guardrails for agentic AI backends.
Failure modes I test before launch
Authorization code is easy to get subtly wrong, so these negative tests run in CI:
- A validly signed token for a different audience is rejected with
401. - A
payments:readtoken callingcreateRefundis rejected with403. - A token with the right scope but a stale
auth_timegets the step-up challenge, not a success. - A CIMD document whose
client_iddiffers from its URL is refused. - A CIMD URL pointing at
169.254.169.254or a private range is refused before any connection opens. - Two replicas, alternating requests, behave identically.
If one of these cannot be written as a test, some state is still hiding in the design.
What I would do on Monday
For a team that already has an MCP server built the earlier Spring AI way:
- Week one: add audience validation and the protected resource metadata. Both are small, they close the most dangerous hole, and they do not depend on any vendor catching up.
-
Week two: move tool authorization onto scopes with
@PreAuthorizeand filter the tool list per caller. - Week three: add the step-up guard to the two or three tools that move money or change a mandate, plus the audit aspect.
- In parallel: ask your identity team for their CIMD plan with dates, and keep DCR enabled behind rate limits until it lands. DCR is deprecated, not removed, so there is a clock but no emergency.
- Last: compare your Spring AI release notes with this list and delete whichever custom code the starters now cover.
None of this is exotic. I spent years on PSD2 consent flows and mandate APIs, and an AI agent is the newest kind of third-party provider: faster and less predictable than a human. The old controls (audience, scope, freshness, audit) still hold, and the 2026-07-28 spec lets you apply them without fighting the transport. More of my payments and AI backend work is under projects, with my background at experience.
If you are planning this migration and want a second pair of eyes on the threat model, send me the architecture through the contact section. If staffing is the open question, my remote developer hiring guide covers hiring a senior Java engineer for EU and ASEAN teams.
FAQ
What is an MCP server?
An MCP server is a program that offers a list of actions and data to an AI assistant through the Model Context Protocol. The assistant asks what the server can do, then calls those actions on a user's behalf.
How do I secure a Spring Boot MCP server?
Treat it as an OAuth 2.1 resource server. Use Spring Security's JWT resource-server support, add an audience validator, guard each tool with a @PreAuthorize scope, require step-up authentication on payment tools, and audit-log every tool call.
What is a Client ID Metadata Document?
A JSON file an AI application hosts at an HTTPS address, with that address serving as its client ID. The authorization server reads the file to learn the app's name and redirect addresses rather than requiring registration first.
Why is Dynamic Client Registration being replaced?
Open registration produces anonymous client records that are hard to trust or clean up. A metadata document ties the client to a domain you can check. DCR is deprecated, not yet removed.


Top comments (0)