Enterprise Java

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 TypeOAuth 2.0 StatusOAuth 2.1 StatusRecommended Replacement
Authorization CodeSupportedSupported + PKCE required
Authorization Code + PKCERecommendedRequired for all clients
Client CredentialsSupportedSupported
Device AuthorizationExtension (RFC 8628)Included
ImplicitDeprecatedRemovedAuth Code + PKCE
Resource Owner PasswordDiscouragedRemovedAuth Code or First-Party Apps draft
Refresh TokenSupportedSupported (rotation recommended)

OAuth grant type attack surface — illustrative severity comparison

Higher score = more attack vectors. Based on published CVEs, IETF Security BCP, and threat model analysis.

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

Number of configuration items to review per migration scenario. Most teams have 2–3 of these.

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 IMPLICIT or PASSWORD as a supported grant type. In Spring Authorization Server, this means reviewing your RegisteredClientRepository beans.
  • Add requireProofKey(true) to all public clients — Any client with ClientAuthenticationMethod.NONE must 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.yml client registrations.
  • Enable refresh token rotation — Set reuseRefreshTokens(false) in your TokenSettings. 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 JwtDecoder should include a custom audience validator that checks the aud claim 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: Bearer headers 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.yml registrations.
  • Add audience (aud) validation to every resource server’s JwtDecoder. 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.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

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

0 Comments
Oldest
Newest Most Voted
Back to top button