OAuth 2.1 and the Death of Implicit Flow: What Every Java Developer Building Auth Needs to Update
OAuth 2.1 consolidates years of security best practices and formally retires the implicit grant, the resource owner password credentials grant, and plain PKCE. Spring Security 6.x already reflects these changes. Here is exactly what to update and why.
OAuth 2.0 was published as RFC 6749 in 2012. In the 13 years since, the security landscape shifted dramatically — cross-site scripting attacks became more sophisticated, token theft from browser memory became a documented attack vector, and the community learned some hard lessons about which grant types were safe in which contexts. The problem is that RFC 6749 never got updated to reflect any of this. Instead, fixes accumulated across separate specs: RFC 7636 added PKCE, RFC 8252 addressed native apps, and the OAuth Security Best Current Practice document tried to stitch the guidance together.
OAuth 2.1 is the consolidation. As of March 2026, it sits at draft-ietf-oauth-v2-1-15 — still an IETF Internet Draft, not a final RFC, but stable enough that Spring Security’s Authorization Server already implements it. For Java developers, the practical question is not “should I wait for the RFC?” — it is “what do I need to change in my Spring Security configuration today?”
This article answers that directly. First we cover what OAuth 2.1 actually removes and adds. Then we look at the Spring Security 6.x APIs that align with those changes. Finally, we walk through the most common migration scenarios with before-and-after code.
Status NoteOAuth 2.1 is still an IETF Internet Draft as of April 2026 (draft-ietf-oauth-v2-1-15, published March 2026). It is not yet a final RFC. However, its security recommendations are already incorporated into Spring Security’s Authorization Server, and all major identity providers treat the retired grant types as deprecated. The practical impact is already here.
1. What OAuth 2.1 actually changes
The short version is that OAuth 2.1 does not introduce new concepts. Rather, it removes dangerous ones, mandates previously optional defences, and tightens loose ends. The oauth.net/2.1 summary captures the major differences clearly. Let us walk through each one.
1. The implicit grant is gone
The implicit flow — where the access token is returned directly in the URL fragment from the authorization endpoint — was originally designed for single-page applications that could not securely store a client secret. It skips the token exchange step entirely, handing the access token to the browser in the redirect URL.
However, this creates several serious problems. The token appears in browser history, server logs, and the Referer header. It cannot be sender-constrained. There is no way to verify that the intended client is the one receiving the token. And because the flow bypasses the token endpoint, you cannot use PKCE to protect it. The authorization code flow with PKCE solves all of these problems and works just as well for browser-based apps — so OAuth 2.1 simply omits implicit entirely.
2. The resource owner password credentials grant is gone
The ROPC grant — where the user provides their username and password directly to the client application, which then exchanges them for a token — was always a pragmatic shortcut, not a recommended pattern. It requires the user to trust the client application with their credentials, defeats the purpose of delegated authorization, and prevents the use of multi-factor authentication or passwordless flows because the client handles credentials directly.
OAuth 2.1 removes it entirely. The recommended alternative for first-party applications that need a native login experience is the Authorization Code Flow, potentially with the emerging OAuth 2.0 for First-Party Applications draft, which provides a native credential challenge mechanism without exposing user credentials to the client.
3. PKCE is now required for all authorization code flows
Proof Key for Code Exchange (PKCE, RFC 7636) was originally introduced to protect native and mobile apps, where the redirect URI interception attack was a known risk. In that attack, a malicious app on the same device registers the same custom URI scheme and intercepts the authorization code before the legitimate app receives it. PKCE prevents this by requiring the client to prove, at the token endpoint, that it generated the original authorization request.
OAuth 2.1 extends the PKCE requirement to all clients using the authorization code flow — including confidential clients (those with a client secret). The reason is that PKCE also protects against authorization code injection attacks, where an attacker substitutes a stolen code into a legitimate flow. The protection costs nothing in terms of complexity and is already widely supported. Additionally, OAuth 2.1 mandates S256 as the only valid challenge method — plain text PKCE (plain) is disallowed.
4. Redirect URIs require exact string matching
Under OAuth 2.0, some implementations allowed wildcard or pattern matching for redirect URIs. This is dangerous: if an attacker can register a URI that pattern-matches your redirect, they can steal the authorization code. OAuth 2.1 mandates exact string matching — the redirect URI in the token request must exactly equal one of the pre-registered URIs, no wildcards, no partial matches.
5. Bearer tokens must not be sent in URL query strings
Passing bearer tokens as query parameters was always risky (tokens appear in server logs and browser history) but technically permitted by RFC 6750. OAuth 2.1 restricts bearer token usage to the Authorization: Bearer header or the request body. This is already the standard in any well-configured Spring Security resource server, but it is now a formal requirement.
| Grant Type | OAuth 2.0 Status | OAuth 2.1 Status | Recommended Replacement |
|---|---|---|---|
| Authorization Code | Supported | Supported + PKCE required | — |
| Authorization Code + PKCE | Recommended | Required for all clients | — |
| Client Credentials | Supported | Supported | — |
| Device Authorization | Extension (RFC 8628) | Included | — |
| Implicit | Deprecated | Removed | Auth Code + PKCE |
| Resource Owner Password | Discouraged | Removed | Auth Code or First-Party Apps draft |
| Refresh Token | Supported | Supported (rotation recommended) | — |
OAuth grant type attack surface — illustrative severity comparison

2. What Spring Security 6.x already does for you
The good news is that Spring Security 6.x — which ships with Spring Boot 3.x — already reflects OAuth 2.1 patterns in both its client and authorization server modules. However, “already supports” does not mean your existing configuration automatically complies. It means the right APIs exist. Whether they are used depends on how you configured things.
Specifically, the Spring Authorization Server (a separate project, spring-authorization-server) is designed to be OAuth 2.1 compliant. It does not offer implicit flow at all. It requires PKCE for public clients by default when clientAuthenticationMethods is set to none. And its RegisteredClient API lets you enforce requireProofKey(true) for any client type.
On the client side, the spring-security-oauth2-client module automatically applies PKCE when client-authentication-method is set to none (public client). For confidential clients, PKCE is opt-in via OAuth2AuthorizationRequestCustomizers.withPkce() — and as of the OAuth 2.1 draft’s requirements, you should be enabling it explicitly even for confidential clients.
3. Migration scenario 1: Replacing the implicit flow in a Spring SPA setup
This is the most common migration task. If your SPA was using response_type=token — the implicit flow — you need to switch to response_type=code with PKCE. The authorization server side and client side both need updating.
Authorization server: register a public client with PKCE required
Before — OAuth 2.0 (implicit allowed)
// RegisteredClient allowing implicit
RegisteredClient.withId(...)
.clientId("my-spa")
.authorizationGrantType(
AuthorizationGrantType.IMPLICIT)
.authorizationGrantType(
AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://app.example.com/*")
.build();
After — OAuth 2.1 compliant
// No implicit; PKCE required
RegisteredClient.withId(...)
.clientId("my-spa")
.clientAuthenticationMethod(
ClientAuthenticationMethod.NONE)
.authorizationGrantType(
AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://app.example.com/callback")
.clientSettings(ClientSettings.builder()
.requireProofKey(true)
.requireAuthorizationConsent(true)
.build())
.build();
Notice three key differences: IMPLICIT is removed entirely, the redirect URI is now an exact match (no wildcard), and requireProofKey(true) enforces PKCE at the server level so no client can skip it.
Full authorization server configuration
SecurityConfig.java — Authorization Server
@Configuration
@EnableWebSecurity
public class AuthServerConfig {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // Enable OpenID Connect
http.exceptionHandling(ex -> ex
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)));
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient publicClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("my-spa")
// No client secret — public client
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
// Exact match — no wildcards
.redirectUri("https://app.example.com/callback")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.clientSettings(ClientSettings.builder()
.requireProofKey(true) // PKCE mandatory
.requireAuthorizationConsent(true)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(15))
.refreshTokenTimeToLive(Duration.ofDays(1))
.reuseRefreshTokens(false) // Rotate refresh tokens
.build())
.build();
return new InMemoryRegisteredClientRepository(publicClient);
}
}
A few points worth highlighting here. First, reuseRefreshTokens(false) enables refresh token rotation — every time a refresh token is used, a new one is issued and the old one is invalidated. This limits the blast radius if a refresh token is stolen. Second, the access token TTL is set to 15 minutes — short enough that a leaked token expires quickly. Third, there is no IMPLICIT grant type anywhere in this configuration, and Spring Authorization Server will not serve it.
4. Migration scenario 2: Removing the password credentials grant from a Spring Boot API
Teams that built internal tooling or CLI clients often used ROPC because it avoided a browser redirect. The migration path depends on who your users are.
For internal tools where the auth server and client are operated by the same organisation, the Authorization Code flow with PKCE works even without a browser — tools like OAuth2 device flow (RFC 8628) or the in-progress OAuth for First-Party Apps draft provide better options. For Spring Boot services talking to each other (machine-to-machine), the right answer was always Client Credentials, not ROPC.
If you are currently issuing a ROPC token request that looks like this:
// Legacy: ROPC — DO NOT use in new code // POST /oauth2/token // grant_type=password // &username=alice // &password=secret // &client_id=my-client // &client_secret=my-secret // This exposes the user's credentials to the client application. // OAuth 2.1 removes this grant entirely.
The replacement for service-to-service calls is the Client Credentials grant. The key difference: no user is involved. The client authenticates as itself, receives an access token scoped to what it is allowed to do, and uses that token to call the resource server. Spring Security makes this straightforward:
application.yml — Client Credentials for service-to-service
spring:
security:
oauth2:
client:
registration:
my-service:
client-id: my-service-client
client-secret: ${CLIENT_SECRET} # From env/vault, never hardcoded
authorization-grant-type: client_credentials
scope: orders.read, inventory.write
provider:
my-service:
token-uri: https://auth.example.com/oauth2/token
On the calling side, inject an OAuth2AuthorizedClientManager and use Spring’s WebClient integration to automatically attach bearer tokens — no manual token management required:
ServiceClient.java — WebClient with automatic token attachment
@Component
public class OrderServiceClient {
private final WebClient webClient;
public OrderServiceClient(OAuth2AuthorizedClientManager authorizedClientManager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
oauth2Client.setDefaultClientRegistrationId("my-service");
this.webClient = WebClient.builder()
.filter(oauth2Client)
.baseUrl("https://orders.internal.example.com")
.build();
}
public List getOrders() {
return webClient.get()
.uri("/orders")
.retrieve()
.bodyToFlux(Order.class)
.collectList()
.block();
}
}
5. Migration scenario 3: Enabling PKCE for a confidential client
Even if you are using a confidential client — a server-side Spring MVC or Spring Boot application that holds a client secret securely — OAuth 2.1 requires PKCE. This is a change from the common OAuth 2.0 pattern where PKCE was only enforced for public clients.
On the client side, Spring Security 6 makes this a one-line addition using OAuth2AuthorizationRequestCustomizers.withPkce():
OAuth2ClientConfig.java — PKCE for a confidential client
@Configuration
public class OAuth2ClientConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
ClientRegistrationRepository clientRegistrationRepository) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint
.authorizationRequestResolver(
pkceRequestResolver(clientRegistrationRepository))));
return http.build();
}
private OAuth2AuthorizationRequestResolver pkceRequestResolver(
ClientRegistrationRepository repo) {
DefaultOAuth2AuthorizationRequestResolver resolver =
new DefaultOAuth2AuthorizationRequestResolver(
repo, "/oauth2/authorization");
// This single line enables PKCE S256 for all authorization requests
resolver.setAuthorizationRequestCustomizer(
OAuth2AuthorizationRequestCustomizers.withPkce());
return resolver;
}
}
When you add this, Spring Security automatically generates a code_verifier, computes a code_challenge using SHA-256, appends code_challenge_method=S256 to the authorization request, and sends the code_verifier at the token exchange step. You do not need to manage any of this manually.
6. Migration scenario 4: Hardening the resource server
Your resource server — the API that validates incoming tokens — also has some housekeeping to do under OAuth 2.1. Specifically, it should reject bearer tokens in query parameters and enforce strict audience validation.
ResourceServerConfig.java — Audience and token validation
@Configuration
public class ResourceServerConfig {
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("https://auth.example.com/oauth2/jwks")
.build();
// Validate audience claim — reject tokens not meant for this service
OAuth2TokenValidator<Jwt> audienceValidator = jwt -> {
List<String> audiences = jwt.getAudience();
if (audiences.contains("orders-api")) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "Token not intended for this service", null));
};
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
new JwtTimestampValidator(Duration.ofSeconds(30)), // 30s clock skew
audienceValidator
));
return decoder;
}
private JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("scope");
grantedAuthoritiesConverter.setAuthorityPrefix("SCOPE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}
}
The audience validator is the most important addition here. Without it, a token issued for a different service is accepted by yours — this is called a confused deputy attack and is listed in the OAuth Security Best Current Practice as a critical issue. Always validate that the aud claim contains the expected identifier for your specific resource server.
Spring Security OAuth2 configuration checklist — items per migration scenario
7. The migration checklist
Before we wrap up, here is a concrete checklist you can apply to an existing Spring Security OAuth2 setup. Each item maps to one of the scenarios above.
- Audit all registered clients — Remove any client that lists
IMPLICITorPASSWORDas a supported grant type. In Spring Authorization Server, this means reviewing yourRegisteredClientRepositorybeans. - Add
requireProofKey(true)to all public clients — Any client withClientAuthenticationMethod.NONEmust enforce PKCE at the server level. Do not rely on the client opting in. - Add PKCE to confidential clients — For server-side Spring Security OAuth2 clients, add
OAuth2AuthorizationRequestCustomizers.withPkce()to your authorization request resolver. - Audit all redirect URIs for exact match — Remove any wildcards or patterns. Each registered redirect URI must be a complete, exact URL. Check both the authorization server registration and your
application.ymlclient registrations. - Enable refresh token rotation — Set
reuseRefreshTokens(false)in yourTokenSettings. This is not strictly required by OAuth 2.1 but is strongly recommended in the Security BCP and in all modern identity provider configurations. - Add audience validation to all resource servers — Every
JwtDecodershould include a custom audience validator that checks theaudclaim against this specific service’s identifier. - Remove any bearer token query parameter usage — Search your codebase for token passing via URL parameters. Move to
Authorization: Bearerheaders in all cases.
Testing TipSpring Security’s @WithMockUser and SecurityMockMvcRequestPostProcessors.jwt() are useful for unit-testing resource server configurations. For integration testing the full OAuth2 flow with PKCE, consider Spring Authorization Server’s test support or tools like Testcontainers with a real Keycloak instance.
8. A note on what OAuth 2.1 still does not solve
It is worth being clear about what OAuth 2.1 is and is not. It is a consolidation of existing best practices — not a new authentication protocol and not a replacement for OpenID Connect. If you are handling user authentication (not just authorization), you should still be using OpenID Connect 1.0 on top of OAuth 2.1, which is exactly what Spring Authorization Server’s OIDC configuration provides.
Furthermore, OAuth 2.1 does not address every modern threat. Token binding — binding a token cryptographically to the client’s TLS connection — is a separate and still-evolving specification. The DPoP (Demonstrating Proof of Possession, RFC 9449) specification provides a practical mechanism for sender-constraining tokens, and Spring Security 6 includes experimental DPoP support. For high-assurance deployments, DPoP is worth evaluating alongside OAuth 2.1’s baseline requirements.
Finally, transitioning away from implicit flow does not automatically fix every SPA auth pattern. Storing tokens in browser memory (JavaScript variables) rather than localStorage reduces XSS exposure, but the real long-term direction for SPAs is the Backend for Frontend (BFF) pattern, where the SPA talks to a backend that handles tokens using secure, HttpOnly cookies — keeping tokens entirely out of client-side JavaScript. This is a broader architectural decision beyond the scope of OAuth 2.1 itself, but it is the direction the security community is converging on.
9. What we have learned
- OAuth 2.1 is still an IETF Internet Draft (draft-ietf-oauth-v2-1-15, March 2026), but its security requirements are already implemented in Spring Security’s Authorization Server and enforced by all major identity providers.
- The implicit grant (
response_type=token) is removed — the access token in the URL fragment is a documented attack vector. Replace it with Authorization Code + PKCE for all browser and SPA clients. - The Resource Owner Password Credentials grant is removed — it exposes user credentials to the client application and prevents MFA. Replace service-to-service flows with Client Credentials.
- PKCE is now required for all clients using the Authorization Code flow, including confidential server-side clients. In Spring Security 6, add
OAuth2AuthorizationRequestCustomizers.withPkce()to your resolver. - Redirect URIs must be exact string matches — remove wildcards from all registered clients and
application.ymlregistrations. - Add audience (
aud) validation to every resource server’sJwtDecoder. Without it, tokens issued for one service are accepted by another — a confused deputy vulnerability. - Enable refresh token rotation (
reuseRefreshTokens(false)) and keep access token TTLs short (15 minutes or less) to limit the damage from any leaked token.





