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:
| Endpoint | Path |
|---|---|
| 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
RegisteredClientRepositorystores client definitions — useJdbcRegisteredClientRepositoryin productionOAuth2TokenCustomizeradds 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
RestClientinterceptor
This completes Part 4: Spring Security. Next: Part 5 — Testing.
Next: Article 29 — Testing Spring Boot Apps: Unit Tests with JUnit 5 and Mockito.