karawaci.kode

2026-07-11 · 8 min

OAuth 2.1 + PKCE Spring Boot Production: 6 Bulan Pengalaman

Sembilan bulan lalu, klien SaaS B2B (compliance + audit tooling, ~340 enterprise client app + ~12k user) butuh upgrade Authorization Server dari Spring Security OAuth2 legacy (deprecated sejak 2020) ke Spring Authorization Server 1.4 dengan OAuth 2.1 + PKCE mandatory.

Setelah 6 bulan production stable, share implementation, 4 incident yang bikin saya tidak tidur, dan apa yang akan saya lakukan beda kalau ulang.

Konteks

  • App: SaaS audit + compliance tracking untuk Indonesian financial institution.
  • Client: ~340 enterprise client app (web SPA + mobile + server-to-server).
  • User: ~12k internal user di klien (financial auditor, compliance officer).
  • Compliance: ISO 27001, SOC 2 Type 2, Bank Indonesia regulation untuk financial app integration.
  • Tim: 6 backend engineer + 1 security engineer + 1 SRE.

Existing OAuth 2.0 setup:

  • Spring Security OAuth2 legacy (spring-security-oauth2 2.5.x).
  • Authorization code flow + implicit flow (deprecated).
  • Custom token format (JWT, but signing key rotation manual).
  • Password grant aktif untuk legacy app (security debt).

Why migrate

  1. Spec compliance: OAuth 2.0 implicit flow sudah deprecated, banyak audit framework flag.
  2. PKCE mandatory di OAuth 2.1 untuk semua client (bukan cuma public).
  3. Spring Security OAuth2 legacy: end-of-life, no security patch.
  4. Token rotation: refresh token rotation lebih ketat di OAuth 2.1.
  5. DPoP (Demonstrating Proof of Possession): optional tapi recommended untuk high-value API.

Architecture: Spring Authorization Server 1.4

Stack:

  • Spring Boot 3.4
  • Spring Authorization Server 1.4 (separate, bukan bundled di Spring Security)
  • Spring Security 6.4
  • Postgres 17 untuk client + token registry
  • Redis untuk session + token introspection cache
  • Java 21 (untuk virtual threads, lihat Java Virtual Threads Spring)
[Client App] → [Authorization Server] → [Token Endpoint] → JWT (RS256, key rotation 90d)

[Resource Server] ← validates JWT (via JWKs endpoint)

Authorization Server config

@Configuration
@EnableWebSecurity
public class AuthServerConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) 
            throws Exception {
        OAuth2AuthorizationServerConfigurer authServerConfigurer = 
            new OAuth2AuthorizationServerConfigurer();
        
        http.securityMatcher(authServerConfigurer.getEndpointsMatcher())
            .with(authServerConfigurer, configurer -> {
                configurer
                    .oidc(Customizer.withDefaults())
                    .authorizationEndpoint(ae -> ae
                        .authorizationRequestConverter(new EnforcePkceConverter())
                    )
                    .tokenEndpoint(te -> te
                        .accessTokenRequestConverter(new EnhancedTokenRequestConverter())
                    );
            })
            .exceptionHandling(eh -> eh.authenticationEntryPoint(
                new LoginUrlAuthenticationEntryPoint("/login")
            ))
            .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));
        
        return http.build();
    }
    
    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbc) {
        return new JdbcRegisteredClientRepository(jdbc);
    }
    
    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbc, 
            RegisteredClientRepository repo) {
        return new JdbcOAuth2AuthorizationService(jdbc, repo);
    }
}

Enforce PKCE for all clients

public class EnforcePkceConverter implements AuthenticationConverter {
    
    @Override
    public Authentication convert(HttpServletRequest request) {
        OAuth2AuthorizationCodeRequestAuthenticationToken auth = 
            (OAuth2AuthorizationCodeRequestAuthenticationToken) 
            new OAuth2AuthorizationCodeRequestAuthenticationConverter().convert(request);
        
        if (auth == null) return null;
        
        // OAuth 2.1: PKCE required for all clients including confidential
        Map<String, Object> additionalParams = auth.getAdditionalParameters();
        if (!additionalParams.containsKey("code_challenge")) {
            throw new OAuth2AuthorizationCodeRequestAuthenticationException(
                new OAuth2Error("invalid_request", 
                    "code_challenge required (OAuth 2.1 PKCE mandate)", null),
                auth);
        }
        
        if (!"S256".equals(additionalParams.get("code_challenge_method"))) {
            throw new OAuth2AuthorizationCodeRequestAuthenticationException(
                new OAuth2Error("invalid_request",
                    "Only S256 code_challenge_method allowed", null),
                auth);
        }
        
        return auth;
    }
}

plain code_challenge_method allowed di spec OAuth 2.1 tapi kami reject — S256 only. Tambahan defense-in-depth.

Refresh token rotation

@Configuration
public class TokenConfig {
    
    @Bean
    public OAuth2TokenGenerator<?> tokenGenerator(JwtEncoder encoder) {
        JwtGenerator jwtGenerator = new JwtGenerator(encoder);
        jwtGenerator.setJwtCustomizer(jwtTokenCustomizer());
        
        OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
        OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
        
        return new DelegatingOAuth2TokenGenerator(
            jwtGenerator, accessTokenGenerator, refreshTokenGenerator);
    }
    
    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {
        return context -> {
            if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
                JwtClaimsSet.Builder claims = context.getClaims();
                claims.claim("tenant_id", getTenantId(context));
                claims.claim("roles", getRoles(context));
                // Short-lived access token: 15 menit
                claims.expiresAt(Instant.now().plus(Duration.ofMinutes(15)));
            }
        };
    }
}

Refresh token TTL: 30 hari. Setiap refresh, rotate token baru (old refresh token immediately invalidated). Detect refresh token reuse = compromised, revoke all tokens for user.

@Component
public class RefreshTokenAuthenticationProvider implements AuthenticationProvider {
    
    @Override
    public Authentication authenticate(Authentication authentication) {
        OAuth2RefreshTokenAuthenticationToken auth = 
            (OAuth2RefreshTokenAuthenticationToken) authentication;
        
        OAuth2Authorization authorization = authorizationService.findByToken(
            auth.getRefreshToken(), OAuth2TokenType.REFRESH_TOKEN);
        
        if (authorization == null) {
            // Refresh token reuse detected!
            // Find all authorizations for this user, revoke all
            revokeAllForUser(extractUserId(auth.getRefreshToken()));
            throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT);
        }
        
        // Rotate refresh token
        OAuth2RefreshToken newRefreshToken = generateRefreshToken();
        authorization.invalidateToken(auth.getRefreshToken());
        // ... build new authorization with new refresh token
    }
}

JWK Set + key rotation

@Bean
public JWKSource<SecurityContext> jwkSource(JwkRepository repo) {
    return (jwkSelector, context) -> {
        List<JWK> activeKeys = repo.findActiveKeys();
        return jwkSelector.select(new JWKSet(activeKeys));
    };
}

// Scheduled rotation every 90 days
@Scheduled(cron = "0 0 2 1 */3 *")  // First day every 3 months at 02:00
public void rotateSigningKey() {
    KeyPair keyPair = generateRsaKeyPair(2048);
    RSAKey rsaKey = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
        .privateKey(keyPair.getPrivate())
        .keyID(UUID.randomUUID().toString())
        .keyUse(KeyUse.SIGNATURE)
        .algorithm(JWSAlgorithm.RS256)
        .build();
    
    jwkRepository.markPreviousAsInactive();  // Old key still valid for verification
    jwkRepository.saveActive(rsaKey);
    
    log.info("Signing key rotated: kid={}", rsaKey.getKeyID());
}

Old key keep alive 30 hari post-rotation untuk validate token yang issued sebelumnya.

Migration: 4 bulan

Phase 1 (4 minggu): provision Authorization Server baru, register all client di DB.

Phase 2 (4 minggu): support dual issue — Authorization Server baru issue token dengan format JWK baru. Resource Server support 2 JWKs endpoint sementara.

Phase 3 (6 minggu): client migrate one-by-one. Communicate sebulan sebelumnya, beri code snippet OAuth 2.1 PKCE library Java + JS.

Phase 4 (2 minggu): sunset legacy Authorization Server.

Yang break (4 incident)

Incident 1: Client mobile lupa update PKCE (Hari 12 post-cutover)

3 client app mobile pakai library OAuth 2.0 lama yang tidak support PKCE. Setelah PKCE enforced, login mobile gagal 100%.

Detection: alert error rate invalid_request: code_challenge required. Mean time to detect: 8 menit (alert spike).

Mitigation: temporary whitelist 3 client itu boleh skip PKCE (granular per client_id) sambil tim mobile update library. Skip duration: 6 hari, removed setelah confirm semua mobile build deployed.

Lesson: client-by-client migration plan butuh confirmation step (bukan asumsi). Pasca insiden, kami buat compliance check di CI client: PKCE library version minimum.

Incident 2: Refresh token rotation race condition (Hari 38)

User open 2 tab browser, refresh token race. Tab A refresh, dapat token baru. Tab B refresh dengan same old token (cached) — server detect “reuse”, revoke all token user. User logged out semua tab.

Root cause: detection logic terlalu agresif. Konkuren refresh dengan token sama dalam window < 1 detik dianggap reuse.

Fix: tambah grace window — kalau old refresh token punya “in-flight rotation” flag dalam 5 detik terakhir, return token rotation result yang sama (idempotent). Implementation:

private static final Duration GRACE_WINDOW = Duration.ofSeconds(5);

public OAuth2AccessToken refresh(String refreshToken) {
    String idempotencyKey = "refresh:" + sha256(refreshToken);
    
    return cache.get(idempotencyKey, () -> {
        OAuth2Authorization authorization = findByRefreshToken(refreshToken);
        // ... validate
        OAuth2RefreshToken newToken = rotate(authorization);
        return tokenResponse(newToken);
    }, GRACE_WINDOW);
}

After fix: false-positive reuse detection drop dari 0.4% ke < 0.01%.

Incident 3: JWKs endpoint cache stampede (Hari 67)

Saat key rotation, semua Resource Server cache JWKs lama, expire bersamaan, fetch baru bersamaan. 340 Resource Server hit Authorization Server JWKs endpoint dalam 200ms window. AuthServer 503 selama 12 detik.

Fix:

  • Resource Server SDK: jitter cache expiry +/- 10%.
  • Authorization Server: cache JWKs response di Redis dengan TTL 5 menit, serve dari cache.
  • Rate limit JWKs endpoint per IP.

After fix: peak QPS JWKs endpoint dari 1.700 (incident moment) ke 280 sustainable.

Incident 4: Token introspection bottleneck (Hari 92)

Beberapa Resource Server legacy pakai token introspection (RFC 7662) instead of JWT self-validation. Setiap request hit /introspect endpoint di Authorization Server.

Saat traffic spike (campaign launch), introspection endpoint p99 latency naik dari 8ms ke 240ms. Downstream Resource Server timeout, cascade error.

Fix:

  • Migrate Resource Server ke JWT self-validation (preferred OAuth 2.1).
  • Redis cache introspection result per access_token (TTL = remaining token lifetime).
  • Database connection pool tune (introspection query): 20 → 80 connection.

After fix: introspection p99 stable 5-8ms. 60% Resource Server migrate ke JWT self-validation, sisanya cache di Redis.

Hasil 6 bulan

MetricSebelum (OAuth 2.0)Setelah (OAuth 2.1)
Client supporting modern flow60%100%
Implicit flow usage18%0%
Password grant usage8%0%
Average token lifetime (access)60 min15 min
Refresh token rotationmanualevery use
Security audit finding (OAuth-related)70
Login failure rate1.4%0.6%
Auth server cost monthly$180 (managed legacy)$240 (self-host SAS + DB)

Cost naik sedikit, tapi audit + security posture jauh lebih baik.

Kapan saya tidak rekomendasi self-host Auth Server

  1. Tim < 8 engineer: Auth Server itu surface area security tinggi, butuh dedicated engineer.
  2. Tidak ada security engineer: tidak boleh maintain Auth Server tanpa security review reguler.
  3. Compliance audit lebih nyaman dengan vendor: Auth0, Okta punya pre-audited setup.

Kapan worth self-host

  1. Customization deep: claim custom per-tenant, integration LDAP/AD korporat.
  2. Data residency: data user/token harus di Indonesia.
  3. Cost predictability: $240/bulan flat vs Auth0 yang scale per-MAU.

Verdict

Spring Authorization Server 1.4 untuk OAuth 2.1 + PKCE mature dan production-ready. Migration ke OAuth 2.1 itu effort, tapi dapat 0 audit finding sekarang vs 7 sebelumnya. Worth.

Bukan magic. 4 incident dalam 6 bulan, 2 di antaranya bikin saya stress malam. Tapi setiap incident sekarang ada runbook + alert. Lihat juga Java Virtual Threads di production untuk konteks performa Spring Boot 3 yang underlying.

Ditulis oleh Reza Pradipta