Building an Enterprise OAuth2 Authorization Server with Spring Boot and Spring Authorization Server: Complete Guide

This guide walks through building a production-ready OAuth2/OIDC authorization server using Spring Boot 3.x and Spring Authorization Server 1.3, covering client registration, authorization code flow with PKCE, custom JWT claims, RSA key rotation, MFA integration, and deployment best practices.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Building an Enterprise OAuth2 Authorization Server with Spring Boot and Spring Authorization Server: Complete Guide

1. Why Build a Custom Authorization Center

In a microservice architecture, authentication and authorization must eventually be extracted from business code. Multiple backend services, SPA frontends, mobile apps, and third-party open platforms each maintaining their own login logic will inevitably cause problems. A centralized authorization center is not the only solution, but it is most likely the most appropriate one.

Early projects used spring-security-oauth2 2.x with @EnableAuthorizationServer, but this stack has been unmaintained since Spring Security 5. The community identifies three main pain points:

Incompatible with new Spring Boot/Spring Cloud filter chain, high upgrade cost.

Almost no OIDC support: no ID Token, no Discovery document, incomplete endpoints and claims.

Too few extension points. Customizing token generation logic, Consent pages, and client management to enterprise-grade usability is difficult.

Therefore, the legacy solution was excluded and Spring's official Spring Authorization Server (SAS) was chosen. SAS is rewritten on Spring Security 6, with relatively complete OAuth2 and OIDC protocol implementations while preserving necessary extension points. The following documents the from-scratch build process and production practices.

2. Core Concepts and Dependency Setup

SAS is not a standalone service; it is a module embedded in the Spring Security filter chain. Adding it automatically exposes core endpoints: /oauth2/authorize — entry point for authorization code requests /oauth2/token — token issuance /oauth2/revoke — token revocation

OIDC endpoints: /.well-known/openid-configuration and /userinfo Versions used: Spring Boot 3.x + SAS 1.3 (Spring Security 6.2 line). Early 1.0 versions are not recommended due to many pitfalls.

Dependency List

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>

The spring-boot-starter-oauth2-authorization-server starter is bundled with SAS; its version follows Spring Boot's BOM, no extra version specification needed.

Core Configuration

SAS's basic pattern is configuring two SecurityFilterChain beans: one for the authorization server's own endpoints, another for regular login requests.

@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.oauth2ResourceServer(resourceServer -> resourceServer.jwt(jwt -> {}));
        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    @Primary
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() throws NoSuchAlgorithmException {
        RSAKey rsaKey = generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

    private static RSAKey generateRsa() throws NoSuchAlgorithmException {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        return new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
    }

    private static KeyPair generateRsaKey() throws NoSuchAlgorithmException {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        keyPairGenerator.initialize(2048);
        return keyPairGenerator.generateKeyPair();
    }
}

Key points in this configuration: @Order(1) on authorizationServerSecurityFilterChain cannot be omitted; otherwise requests may fall to the default filter chain, causing incorrect session validation. @Primary on jwtDecoder prevents bean injection conflicts because the default filter chain also looks for a JwtDecoder.

The generateRsa() method uses KeyPairGenerator with 2048-bit RSA. Temporary RSA key generation is fine for development, but production must load keys from an external KeyStore or KMS.

3. Client Registration Management

The authorization center must know which callers are legitimate. A "client" is a React frontend, mobile app, or internal service. Clients must register first to obtain client_id and client_secret before running authorization flows.

In SAS, client configuration read/write is handled by RegisteredClientRepository. Options include in-memory, JDBC, or custom implementations. JDBC is used here; SAS provides official schema scripts to run in the database.

Centralized storage and management entry points are essential for maintainability when business systems integrate.

@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
    JdbcRegisteredClientRepository repository = new JdbcRegisteredClientRepository(jdbcTemplate);

    // Startup check: insert example client if missing — production should use admin console, not startup logic
    String clientId = "portal-web";
    if (repository.findByClientId(clientId) == null) {
        RegisteredClient portalClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId(clientId)
                .clientSecret("{noop}portal-secret")
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("https://app.example.com/login/oauth2/code/portal")
                .scope(OidcScopes.OPENID)
                .scope("profile")
                .scope("user.read")
                .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
                .tokenSettings(TokenSettings.builder()
                        .accessTokenTimeToLive(Duration.ofMinutes(30))
                        .refreshTokenTimeToLive(Duration.ofDays(7))
                        .build())
                .build();
        repository.save(portalClient);
    }
    return repository;
}

Practical details often overlooked:

redirect_uri matching is exact — the URL registered by the client must exactly match the redirect_uri parameter in the authorization request. For multi-environment (dev/staging/prod), register all valid callback URLs upfront or provide a management UI.

Grant types must align with client type . Browser-based web apps and SPAs only need AUTHORIZATION_CODE + REFRESH_TOKEN; do not grant client_credentials (service-to-service) to browsers as credentials would be exposed. Internal service-to-service calls suit client_credentials (no user consent).

{noop} prefix means plaintext password . Never use in production. Use {bcrypt} with DelegatingPasswordEncoder which selects the algorithm by prefix — same logic as hashing user passwords.

For a dynamic admin console (approval workflow for client creation), swap RegisteredClientRepository with a DB-backed implementation. The admin UI reuses the same repository; after CRUD operations, broadcast config changes for hot reload.

4. Complete Authorization Code Flow

The authorization code flow is the most common OAuth2 flow and the primary one used in OIDC login.

4.1 Request Authorization

When a user accesses a web app and is not logged in, the app redirects the browser to the authorization server:

GET /oauth2/authorize?response_type=code
    &client_id=portal-web
    &redirect_uri=https://app.example.com/login/oauth2/code/portal
    &scope=openid%20profile%20user.read
    &state=xyz

The authorization server checks the user's session. If not logged in, it 302 redirects to /login — the login page provided by formLogin in defaultSecurityFilterChain.

4.2 User Login and Consent

After username/password submission, Spring Security's authentication filter chain takes over. On success, Authentication is placed in the context (default session-based).

The flow then returns to the authorization chain, handled by OAuth2AuthorizationCodeRequestAuthenticationProvider, which checks whether the user has previously consented to the requested scopes.

If the client has requireAuthorizationConsent(true) and the user hasn't consented, a Consent page is shown listing requested scopes. SAS ships a basic default page; enterprises typically build a branded one. Implement a /oauth2/consent endpoint to render the page and persist an AuthorizationConsent on submit. The SAS repository's ConsentController is a reference implementation.

4.3 Exchange Code for Token

After user consent, the authorization server redirects back to the client's registered callback with code and state. state must be validated to prevent CSRF.

The client's backend then calls the token endpoint:

POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic cG9ydGFsLXdlYjpwb3J0YWwtc2VjcmV0

grant_type=authorization_code
&code=ABC123
&redirect_uri=https://app.example.com/login/oauth2/code/portal

The Authorization header uses Basic Auth: client_id:client_secret Base64-encoded.

Response JSON:

{
  "access_token": "eyJraWQiOiI...",
  "token_type": "Bearer",
  "expires_in": 1800,
  "refresh_token": "V8u...",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6..."
}

For SPAs that cannot securely store client_secret, use PKCE. SAS natively supports PKCE with S256 code challenge; just pass code_challenge in the authorization request. To enforce PKCE, configure

.clientSettings(ClientSettings.builder().requireProofKey(true).build())

— SAS will reject any authorization request lacking code_challenge, eliminating the risk of stolen authorization codes being exchanged for tokens.

5. Customizing JWT: Claims Extension, Signing Algorithm, and Key Management

Real-world business often needs more than sub and scope. Resource servers need employee ID, roles, department for fine-grained access control. Per OIDC, ID Token should stay minimal; put business attributes in Access Token or let resource servers fetch via /userinfo or internal APIs.

In SAS, add extra claims by implementing OAuth2TokenCustomizer<JwtEncodingContext>:

@Component
public class CustomTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {

    private final EmployeeService employeeService;

    @Override
    public void customize(JwtEncodingContext context) {
        // Only customize access token, leave refresh token and id token untouched
        if (!OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
            return;
        }

        Authentication principal = context.getPrincipal();
        if (principal != null && principal.getPrincipal() instanceof UserDetails user) {
            Employee employee = employeeService.findByUsername(user.getUsername());

            Map<String, Object> employeeClaims = new HashMap<>();
            employeeClaims.put("employee_id", employee.getEmployeeId());
            employeeClaims.put("department", employee.getDepartmentName());
            employeeClaims.put("roles", user.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.toSet()));

            context.getClaims().claims(claims -> claims.putAll(employeeClaims));
        }
    }
}

Note: avoid chaining context.getClaims().claim(...) calls; use the batch form claims(claims -> claims.putAll(map)) for clarity.

Registering this bean lets SAS's NimbusJwtEncoder automatically invoke the customizer before JWT generation.

Signing algorithm defaults to RS256 (RSA private key signs, public key from JWKS verifies). RSA key pairs don't need manual management; just load them into JWKSource:

@Bean
public JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
    return new NimbusJwtEncoder(jwkSource);
}

Key management details — a common trouble spot. Load RSA key from KeyStore: private key in PKCS12 file, path/password/alias via environment variables. Create a JWKSource implementation that reads from KeyStore, builds RSAKey, and wraps in JWKSet:

@Bean
public JWKSource<SecurityContext> jwkSource() throws Exception {
    String keyStorePath = env.getProperty("security.keystore.path");
    String keyStorePassword = env.getProperty("security.keystore.password");
    String alias = env.getProperty("security.keystore.alias");

    KeyStore keyStore = KeyStore.getInstance("PKCS12");
    try (InputStream is = new FileInputStream(keyStorePath)) {
        keyStore.load(is, keyStorePassword.toCharArray());
    }

    KeyStoreKeyFactory keyFactory = new KeyStoreKeyFactory(keyStore, keyStorePassword.toCharArray());
    KeyPair keyPair = keyFactory.getKeyPair(alias);
    RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
    RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

    RSAKey rsaKey = new RSAKey.Builder(publicKey)
            .privateKey(privateKey)
            .keyID(alias) // explicit kid, not random UUID, so rotation can track
            .build();
    return new ImmutableJWKSet(new JWKSet(rsaKey));
}

For key rotation, JWKSet can hold multiple RSAKey entries with distinct kid values. The JWT header's kid identifies which private key signed the token; verifiers fetch the matching public key from the JWK Set. Recommended practice: when publishing a new private key, retain the old one with a grace period (1-2 days) so in-flight old tokens still validate via the old public key. Continue signing new tokens with the new key. Once traffic fully migrates, remove the old key from the JWKSet.

6. Enabling Resource Servers to Validate Tokens via Discovery

The authorization center isn't the endpoint — downstream resource servers/API gateways must validate each request's JWT. SAS automatically exposes:

/.well-known/openid-configuration
/oauth2/jwks

These provide OIDC configuration and public key sets, allowing clients and resource servers to auto-initialize without hardcoding keys or endpoints.

Resource Server Configuration

Add dependency:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

In application.yml, specify issuer:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com

Spring Boot auto-requests https://auth.example.com/.well-known/openid-configuration, reads jwks_uri, and fetches public keys.

If local development domain mismatches cause discovery failure, explicitly set jwk-set-uri as a fallback.

Define security rules:

@Configuration
public class ResourceServerConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.oauth2ResourceServer(resourceServer -> resourceServer.jwt(Customizer.withDefaults()))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/user/**").hasAuthority("SCOPE_user.read")
                .anyRequest().authenticated()
            );
        return http.build();
    }
}

The expression hasAuthority("SCOPE_user.read") checks for scope=user.read in the token. The SCOPE_ prefix is required because Spring Security's JwtGrantedAuthoritiesConverter prefixes each scope value with SCOPE_ when mapping to authorities.

The single issuer-uri configuration covers all necessities (discovery to JWK Set, default issuer validation), eliminating much boilerplate JwtDecoder code.

7. Integrating Existing User Systems and MFA into the Authorization Server

Most enterprises have existing user stores — custom databases, LDAP, AD, or a central middleware. SAS doesn't bind to any user source; any Spring Security UserDetailsService or AuthenticationProvider implementation works.

7.1 Connecting Existing User Store

Implement existing user lookup as UserDetailsService:

@Service
public class ExistingUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AppUser appUser = userRepository.findByLoginId(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
        return User.withUsername(appUser.getLoginId())
                .password(appUser.getPasswordHash())
                .roles(appUser.getRoleCodes().toArray(String[]::new))
                .disabled(appUser.isDisabled())
                .build();
    }
}

Replacing Spring Security's default user details source makes the login form automatically validate against it.

LDAP scenarios work similarly with LdapUserDetailsService or LdapAuthenticationProvider — just adapt the login request; SAS itself needs no code changes. Note: the user entity and Spring Security's UserDetails are different models; we only map between them, not shove the entire user lifecycle into UserDetailsService — persistence and updates remain in original business code.

7.2 MFA

Enterprise OAuth2 authorization centers almost always require MFA. Two common forms:

TOTP : Authenticator app 6-digit codes, works offline.

SMS/Email OTP : One-time code sent per login.

Spring Security lacks built-in MFA flows, so integration adds work. Our approach layers MFA into the default login chain:

User submits username/password at /login; DaoAuthenticationProvider verifies password.

On success, don't release yet; check if user has MFA enrolled.

If enrolled, store the password-verified Authentication in session with a marker (e.g., pendingMfaUserId), redirect to MFA page (e.g., /mfa).

User submits code at /mfa; TotpAuthenticationFilter retrieves pending auth from session, validates TOTP.

On success, build full Authentication, set in SecurityContext, redirect back to /oauth2/authorize.

Key insight: the authorization endpoint /oauth2/authorize does not care which login method was used . It only checks whether a valid Authentication exists in SecurityContext. This lets MFA be a pre-step — once MFA completes and sets SecurityContext, the OAuth2 flow proceeds unchanged.

Implementation structure:

public class TotpAuthenticationFilter extends OncePerRequestFilter {

    private final TotpValidator totpValidator;
    private final SessionMfaRegistry mfaRegistry;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String servletPath = request.getServletPath();

        if ("/mfa".equals(servletPath) && "POST".equals(request.getMethod())) {
            Authentication pendingAuth = mfaRegistry.retrieve(request.getSession());
            if (pendingAuth != null) {
                String code = request.getParameter("totpCode");
                String username = pendingAuth.getName();
                if (totpValidator.isValid(username, code)) {
                    // Validation passed, write full Authentication to SecurityContext
                    SecurityContextHolder.getContext().setAuthentication(pendingAuth);
                    mfaRegistry.clear(request.getSession());
                    // Continue authorization chain; SAS sees authenticated SecurityContext and proceeds
                    chain.doFilter(request, response);
                    return;
                }
            }
            response.sendRedirect("/login?mfa_error=true");
            return;
        }
        chain.doFilter(request, response);
    }
}

TOTP validation can use com.warrenstrange:googleauth library. SessionMfaRegistry encapsulates temporary storage/retrieval of pending authentication. A common pitfall: directly session.setAttribute serializing Authentication fails if UserDetails inside doesn't implement Serializable (e.g., when session persists to Redis). Store only simple data (e.g., username), then re-invoke UserDetailsService after MFA success to rebuild the authentication object.

8. Production Security and Deployment Essentials

8.1 HTTPS

Basic but often missed in deployment. In OAuth2, cookies, authorization codes, tokens, passwords — any cleartext capture is a security incident . Therefore:

Enforce TLS at all ingress layers. If Nginx/gateway terminates TLS, the auth server can listen on 8080/internal traffic only.

Critical: the app must know the external protocol is HTTPS, otherwise generated issuer may be http://, breaking OIDC discovery and client auto-config. In application.yml add:

server:
  forward-headers-strategy: framework

This lets the framework recognize X-Forwarded-Proto: https from the reverse proxy, so all self-generated links use https://. Just this config isn't enough; Nginx must forward X-Forwarded-Proto correctly in its location block, otherwise the framework never sees the real protocol header and cannot generate correct HTTPS links.

8.2 Cookie and Session Isolation

The authorization server should have a dedicated domain or path prefix, isolating cookies from other business systems:

If co-hosted on same domain, set session cookie SameSite=Lax or SameSite=Strict. CSRF risk mainly from cross-site requests; restricting SameSite effectively mitigates.

Ensure cookies carry Secure and HttpOnly flags.

JavaScript must not read Session Cookie — if readable, XSS can hijack the session. Spring Security enables this by default; just ensure config doesn't override it.

For multi-tenancy, avoid sharing a single cookie session globally; use separate context-paths or domains per tenant to prevent cross-tenant session leakage.

SAS enables CSRF protection by default. If custom /login or consent POST logic omits CSRF token, the login chain becomes CSRF-vulnerable. A common issue: CSRF disabled in dev (tests pass) but enabled in prod — POST requests return 403, hard to diagnose. Keep CSRF enabled by default; never set csrf(AbstractHttpConfigurer::disable).

8.3 Key Rotation

Additional production key lifecycle practices:

Production private keys must reside in KeyStore (PKCS12) or HSM. App loads from env-specified path at startup; private key exists only in memory at runtime, never leaked to business code or config files. kid must not be random. Early dev used UUIDs; rotation became untraceable. Switching to alias/version fixed tracking.

Rotation frequency typically 30-90 days. After publishing new key, monitor verification failure rate (rising old-key failures indicate clients still using old public key). Confirm alerts and user feedback before removing old public key.

Multi-instance deployments benefit from a unified external config center (Nacos, Consul) or scheduler to sync new keys; otherwise each rotation requires full redeploy, hurting ops efficiency.

8.4 Deployment Model and Monitoring

The auth center is a typical stateless service (session data in Redis), so K8s multi-replica deployment works fine. Watch for:

Shared session storage is mandatory; otherwise a user logs in on one replica, next request load-balances to another, forcing re-login.

Monitor core metrics: /oauth2/token success rate and P99 latency, /oauth2/authorize request volume, Consent page conversion rate. No need to build from scratch — Micrometer + Prometheus/Grafana.

Audit logs should include at minimum: timestamp, user identifier, client ID, grant type, requested scopes, client IP, final decision (approve/deny). We extracted this into a Logback appender writing to a separate audit log file/channel, so compliance can directly hand files to auditors.

Closing Notes

On technology selection, Spring Authorization Server replacing legacy @EnableAuthorizationServer is a foregone conclusion. However, SAS SPI practices are sparsely documented; online info is fragmented and mixed quality, so early pitfalls mostly come from debugging defaults and reading source code.

This article covers the full path from client registration through authorization code flow, JWT claims customization, MFA integration, to production security practices. The framework design and code have run in our production for over two years with reliable stability. Follow along: first get standard OAuth2/OIDC flows working locally, then progressively swap encryption, storage, and user systems, ultimately producing an authentication infrastructure tailored to your enterprise scenario.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Spring BootJWTOAuth2MFASpring Authorization ServerOIDCKey RotationMicroservices Security
Xiaolin Talks Programming
Written by

Xiaolin Talks Programming

Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.