OAuth2 Authorization Server with Spring Security

Most teams use a managed auth provider (Keycloak, Auth0). But sometimes you need your own — multi-tenant SaaS, air-gapped environments, or full control over token contents. Spring Authorization Server provides a production-ready OAuth2 + OIDC implementation.

When to Build Your Own vs Use a Provider

Use a managed provider (Keycloak/Auth0): Most applications. Faster to set up, maintained externally, handles compliance.

Build your own: Multi-tenant platforms issuing tokens on behalf of tenant auth providers, air-gapped or regulated environments, products that ARE the identity provider, or when you need full control over token structure and storage.

Setup

Create a separate Spring Boot application — the Authorization Server is its own service.

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

Core Configuration

@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {

    // The authorization server's security filter chain
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerChain(HttpSecurity http) throws Exception {
        // Apply default OAuth2 authorization server settings
        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)
                )
            )
            .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));

        return http.build();
    }

    // Standard login form security
    @Bean
    @Order(2)
    public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }

    // RSA key pair for signing tokens
    @Bean
    public JWKSource<SecurityContext> jwkSource() throws Exception {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        keyPairGenerator.initialize(2048);
        KeyPair keyPair = keyPairGenerator.generateKeyPair();

        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

        RSAKey rsaKey = new RSAKey.Builder(publicKey)
            .privateKey(privateKey)
            .keyID(UUID.randomUUID().toString())
            .build();

        return new ImmutableJWKSet<>(new JWKSet(rsaKey));
    }

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

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
            .issuer("https://auth.devopsmonk.com")
            .build();
    }
}

Important: In production, load the RSA key from a key store or secrets manager, not generated on startup. A new key on each restart invalidates all existing tokens.

// Load from keystore for production
@Bean
public JWKSource<SecurityContext> jwkSource(
        @Value("${auth.keystore.path}") Resource keystorePath,
        @Value("${auth.keystore.password}") String keystorePassword,
        @Value("${auth.keystore.alias}") String keyAlias) throws Exception {

    KeyStore keyStore = KeyStore.getInstance("JKS");
    keyStore.load(keystorePath.getInputStream(), keystorePassword.toCharArray());

    RSAKey rsaKey = RSAKey.load(keyStore, keyAlias, keystorePassword.toCharArray());
    return new ImmutableJWKSet<>(new JWKSet(rsaKey));
}

Registered Clients — Who Can Request Tokens?

In OAuth2, a client is an application that requests tokens:

@Bean
public RegisteredClientRepository registeredClientRepository() {
    // Client credentials flow — for service-to-service
    RegisteredClient serviceClient = RegisteredClient
        .withId(UUID.randomUUID().toString())
        .clientId("order-service")
        .clientSecret("{bcrypt}$2a$12$...")  // BCrypt hash of the secret
        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
        .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
        .scope("read:orders")
        .scope("write:orders")
        .tokenSettings(TokenSettings.builder()
            .accessTokenTimeToLive(Duration.ofMinutes(15))
            .build())
        .build();

    // Authorization code + PKCE — for browser/mobile apps
    RegisteredClient webClient = RegisteredClient
        .withId(UUID.randomUUID().toString())
        .clientId("devopsmonk-web")
        .clientSecret("{noop}secret")  // no secret for public clients
        .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)  // public client
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        .redirectUri("https://app.devopsmonk.com/callback")
        .redirectUri("http://localhost:3000/callback")  // for development
        .postLogoutRedirectUri("https://app.devopsmonk.com/")
        .scope(OidcScopes.OPENID)
        .scope(OidcScopes.PROFILE)
        .scope(OidcScopes.EMAIL)
        .scope("read:orders")
        .clientSettings(ClientSettings.builder()
            .requireAuthorizationConsent(true)   // show consent screen
            .requireProofKey(true)               // PKCE required
            .build())
        .tokenSettings(TokenSettings.builder()
            .accessTokenTimeToLive(Duration.ofMinutes(15))
            .refreshTokenTimeToLive(Duration.ofDays(7))
            .reuseRefreshTokens(false)           // rotate refresh tokens
            .build())
        .build();

    return new InMemoryRegisteredClientRepository(serviceClient, webClient);
}

For production, store clients in a database:

@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
    return new JdbcRegisteredClientRepository(jdbcTemplate);
}

Spring Authorization Server provides the schema for this table.

Database-Backed Authorization Storage

@Bean
public OAuth2AuthorizationService authorizationService(
        JdbcTemplate jdbcTemplate,
        RegisteredClientRepository clientRepository) {
    return new JdbcOAuth2AuthorizationService(jdbcTemplate, clientRepository);
}

@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(
        JdbcTemplate jdbcTemplate,
        RegisteredClientRepository clientRepository) {
    return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, clientRepository);
}

Customizing Token Claims

Add custom claims to issued tokens:

@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
    return context -> {
        if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
            Authentication principal = context.getPrincipal();

            if (principal instanceof UsernamePasswordAuthenticationToken auth) {
                UserDetails user = (UserDetails) auth.getPrincipal();

                // Add roles to token
                Set<String> roles = user.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.toSet());
                context.getClaims().claim("roles", roles);

                // Add user ID
                if (user instanceof User u) {
                    context.getClaims().claim("user_id", u.getId().toString());
                    context.getClaims().claim("email", u.getEmail());
                }
            }
        }

        // Customize ID token (OIDC)
        if (context.getTokenType().getValue().equals(OidcParameterNames.ID_TOKEN)) {
            Authentication principal = context.getPrincipal();
            if (principal.getPrincipal() instanceof UserDetails user) {
                context.getClaims().claim("preferred_username", user.getUsername());
            }
        }
    };
}

The Authorization Code + PKCE Flow

For browser and mobile clients:

1. App generates code_verifier (random string) and code_challenge (SHA256 hash)

2. Redirect user to:
   GET https://auth.devopsmonk.com/oauth2/authorize
     ?response_type=code
     &client_id=devopsmonk-web
     &redirect_uri=https://app.devopsmonk.com/callback
     &scope=openid profile read:orders
     &state=random-state
     &code_challenge=base64url(SHA256(code_verifier))
     &code_challenge_method=S256

3. User logs in and consents

4. Auth Server redirects to:
   https://app.devopsmonk.com/callback?code=AUTHORIZATION_CODE&state=random-state

5. App exchanges code for tokens:
   POST https://auth.devopsmonk.com/oauth2/token
   Content-Type: application/x-www-form-urlencoded

   grant_type=authorization_code
   &client_id=devopsmonk-web
   &code=AUTHORIZATION_CODE
   &redirect_uri=https://app.devopsmonk.com/callback
   &code_verifier=ORIGINAL_CODE_VERIFIER  ← proves it's the same client

6. Auth Server returns:
   {
     "access_token": "eyJ...",
     "token_type": "Bearer",
     "expires_in": 900,
     "refresh_token": "...",
     "id_token": "eyJ..."  ← OIDC ID token
   }

Client Credentials Flow (Service-to-Service)

# Service A calling Service B
curl -X POST https://auth.devopsmonk.com/oauth2/token \
  -H "Authorization: Basic $(echo -n 'order-service:secret' | base64)" \
  -d "grant_type=client_credentials&scope=read:orders"

# Response
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 900
}

In Spring Boot (Resource Server), call another service:

@Configuration
public class OAuth2ClientConfig {

    @Bean
    public OAuth2AuthorizedClientManager clientManager(
            ClientRegistrationRepository clientRegRepo,
            OAuth2AuthorizedClientRepository authorizedClientRepo) {

        ClientCredentialsOAuth2AuthorizedClientProvider provider =
            OAuth2AuthorizedClientProviderBuilder.builder()
                .clientCredentials()
                .build();

        DefaultOAuth2AuthorizedClientManager manager =
            new DefaultOAuth2AuthorizedClientManager(clientRegRepo, authorizedClientRepo);
        manager.setAuthorizedClientProvider(provider);
        return manager;
    }

    @Bean
    public RestClient inventoryServiceClient(OAuth2AuthorizedClientManager clientManager) {
        OAuth2ClientHttpRequestInterceptor interceptor =
            new OAuth2ClientHttpRequestInterceptor(clientManager);

        return RestClient.builder()
            .baseUrl("https://inventory-service.internal")
            .requestInterceptor(interceptor)  // auto-adds Bearer token
            .build();
    }
}
spring:
  security:
    oauth2:
      client:
        registration:
          inventory-service:
            client-id: order-service
            client-secret: ${INVENTORY_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: read:inventory
        provider:
          inventory-service:
            token-uri: https://auth.devopsmonk.com/oauth2/token

The OIDC Endpoints

Spring Authorization Server exposes all standard OIDC endpoints:

EndpointPath
Authorization/oauth2/authorize
Token/oauth2/token
Token Introspection/oauth2/introspect
Token Revocation/oauth2/revoke
JWKS/oauth2/jwks
OIDC Discovery/.well-known/openid-configuration
OIDC User Info/userinfo
OIDC End Session/connect/logout

Test the discovery endpoint:

curl https://auth.devopsmonk.com/.well-known/openid-configuration | jq .

Key Deployment Notes

# Production configuration
auth:
  keystore:
    path: /secrets/auth-keystore.jks
    password: ${KEYSTORE_PASSWORD}
    alias: auth-signing-key

spring:
  datasource:
    url: ${DB_URL}  # Persistent DB for clients, tokens, consents
  session:
    store-type: redis  # Distribute sessions across instances
  • Store the RSA key in a secrets manager (Vault, AWS Secrets Manager) — never in the image
  • Use a persistent database for registered clients, authorizations, and consents
  • Use Redis for session storage when running multiple instances
  • Set up key rotation — RSA keys should be rotated regularly; old keys must remain available until all tokens signed with them expire

What You’ve Learned

  • Spring Authorization Server implements the full OAuth2 + OIDC specification
  • Two security filter chains: one for OAuth2 endpoints, one for the login UI
  • RSA key pair signs tokens — load from keystore in production, not generated at startup
  • RegisteredClientRepository stores client definitions — use JdbcRegisteredClientRepository in production
  • OAuth2TokenCustomizer adds custom claims (roles, user_id, email) to access tokens
  • PKCE is required for public clients (browsers, mobile apps) — prevents code interception attacks
  • Use client credentials flow for service-to-service communication with a RestClient interceptor

This completes Part 4: Spring Security. Next: Part 5 — Testing.

Next: Article 29 — Testing Spring Boot Apps: Unit Tests with JUnit 5 and Mockito.