Part 15 of 30
Spring Authorization Server: Build Your Own OAuth2 Server
What Is Spring Authorization Server?
Spring Authorization Server (SAS) is an official Spring project that implements OAuth 2.1 and OpenID Connect 1.0 as a Spring Boot application. It provides a complete authorization server you can host yourself — issuing tokens for your own clients and APIs.
Use it when:
- You want SSO across your own microservices
- You cannot use a hosted provider (Okta, Auth0) due to compliance or cost
- You need complete control over token format and claims
- You’re building a platform where other apps authenticate against your identity service
Architecture
flowchart TD
SPA[SPA / Mobile App\nOAuth2 Client] -->|Authorization Code + PKCE| SAS
Service[Backend Service\nClient Credentials| SAS]
SAS[Spring Authorization Server\nIssues tokens]
RS1[Your REST API\nResource Server] -->|Validates JWT| SAS
RS2[Another API\nResource Server] -->|Validates JWT| SAS
SAS -->|Signs tokens with| PK[Private Key\nJWK Set at /.well-known/jwks.json]
RS1 -->|Fetches public key from| PK
RS2 -->|Fetches public key from| PK
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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Minimal Authorization Server Configuration
@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {
// Chain 1: Authorization server endpoints
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // Enable OIDC (userinfo endpoint, etc.)
http.exceptionHandling(ex -> ex
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
);
return http.build();
}
// Chain 2: Form login for users (the consent and login UI)
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
// The registered clients (who is allowed to request tokens)
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("web-client")
.clientSecret("{noop}web-secret") // use BCrypt in production
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("https://web.example.com/login/oauth2/code/my-auth-server")
.postLogoutRedirectUri("https://web.example.com/")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope(OidcScopes.EMAIL)
.scope("read:products")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true) // show consent screen
.requireProofKey(true) // require PKCE
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(15))
.refreshTokenTimeToLive(Duration.ofDays(7))
.reuseRefreshTokens(false) // token rotation
.build())
.build();
RegisteredClient serviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("service-client")
.clientSecret("{bcrypt}" + new BCryptPasswordEncoder().encode("service-secret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("read:products")
.scope("write:orders")
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofHours(1))
.build())
.build();
return new InMemoryRegisteredClientRepository(webClient, serviceClient);
}
// JWK Set — key pair used to sign tokens
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
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));
}
private static KeyPair generateRsaKey() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("https://auth.example.com") // this server's URL
.build();
}
}
Standard Endpoints (Auto-Configured)
| Endpoint | Purpose |
|---|---|
GET /oauth2/authorize | Start authorization flow (redirect users here) |
POST /oauth2/token | Exchange code/credentials for tokens |
POST /oauth2/introspect | Validate opaque tokens |
POST /oauth2/revoke | Revoke access/refresh tokens |
GET /userinfo | Return user profile (OIDC) |
GET /.well-known/openid-configuration | Discovery document |
GET /.well-known/jwks.json | Public keys for JWT validation |
Database-Backed Client Registration
-- Required tables (Spring Authorization Server provides schema scripts)
CREATE TABLE oauth2_registered_client (
id VARCHAR(100) NOT NULL PRIMARY KEY,
client_id VARCHAR(100) NOT NULL UNIQUE,
client_id_issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
client_secret VARCHAR(200),
client_secret_expires_at TIMESTAMP,
client_name VARCHAR(200) NOT NULL,
client_authentication_methods VARCHAR(1000) NOT NULL,
authorization_grant_types VARCHAR(1000) NOT NULL,
redirect_uris VARCHAR(1000),
post_logout_redirect_uris VARCHAR(1000),
scopes VARCHAR(1000) NOT NULL,
client_settings VARCHAR(2000) NOT NULL,
token_settings VARCHAR(2000) NOT NULL
);
-- Authorization + token tables (many more — use the provided schema)
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
return new JdbcRegisteredClientRepository(jdbcTemplate);
}
@Bean
public OAuth2AuthorizationService authorizationService(
JdbcTemplate jdbcTemplate,
RegisteredClientRepository registeredClientRepository
) {
return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}
@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(
JdbcTemplate jdbcTemplate,
RegisteredClientRepository registeredClientRepository
) {
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}
Custom Token Claims
Add custom claims to access tokens and ID tokens:
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(UserRepository userRepository) {
return context -> {
if (context.getPrincipal() instanceof UsernamePasswordAuthenticationToken auth) {
AppUserDetails user = (AppUserDetails) auth.getPrincipal();
User dbUser = userRepository.findById(user.getId()).orElseThrow();
// Add to access token
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())
|| OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
context.getClaims()
.claim("userId", dbUser.getId())
.claim("email", dbUser.getEmail())
.claim("roles", dbUser.getRoles().stream().map(Role::name).toList());
}
// Add extra claims only to ID token
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
context.getClaims()
.claim("picture", dbUser.getAvatarUrl())
.claim("given_name", dbUser.getFirstName())
.claim("family_name", dbUser.getLastName());
}
}
};
}
Custom Consent Screen
Replace Spring’s minimal consent UI with a branded page:
@Controller
public class ConsentController {
private final OAuth2AuthorizationConsentService consentService;
private final RegisteredClientRepository clientRepository;
@GetMapping("/oauth2/consent")
public String consent(
@RequestParam String clientId,
@RequestParam String scope,
@RequestParam String state,
@AuthenticationPrincipal Principal principal,
Model model
) {
RegisteredClient client = clientRepository.findByClientId(clientId);
// Parse requested scopes and add descriptions
Set<String> requestedScopes = Arrays.stream(scope.split(" ")).collect(toSet());
Set<ScopeWithDescription> scopesWithDesc = requestedScopes.stream()
.map(s -> new ScopeWithDescription(s, getScopeDescription(s)))
.collect(toSet());
model.addAttribute("clientName", client.getClientName());
model.addAttribute("scopes", scopesWithDesc);
model.addAttribute("state", state);
model.addAttribute("clientId", clientId);
model.addAttribute("username", principal.getName());
return "consent"; // consent.html
}
private String getScopeDescription(String scope) {
return switch (scope) {
case "openid" -> "Access your identity";
case "profile" -> "Read your name and profile";
case "email" -> "Read your email address";
case "read:products" -> "Read products from our store";
case "write:orders" -> "Create orders on your behalf";
default -> scope;
};
}
}
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.authorizationEndpoint(endpoint -> endpoint
.consentPage("/oauth2/consent") // use custom consent page
);
Persisting Keys to Disk
Generating new keys on every startup invalidates all existing tokens. For production, persist the key pair:
# application.yml
app:
security:
jwk:
key-store: /secrets/auth-server-keystore.p12
key-store-password: ${KEY_STORE_PASSWORD}
key-alias: auth-server-key
@Bean
public JWKSource<SecurityContext> jwkSource(
@Value("${app.security.jwk.key-store}") Resource keyStore,
@Value("${app.security.jwk.key-store-password}") String password,
@Value("${app.security.jwk.key-alias}") String alias
) throws Exception {
KeyStore ks = KeyStore.getInstance("PKCS12");
try (InputStream is = keyStore.getInputStream()) {
ks.load(is, password.toCharArray());
}
RSAPrivateKey privateKey = (RSAPrivateKey) ks.getKey(alias, password.toCharArray());
RSAPublicKey publicKey = (RSAPublicKey) ks.getCertificate(alias).getPublicKey();
RSAKey rsaKey = new RSAKey.Builder(publicKey).privateKey(privateKey)
.keyID(alias).build();
return new ImmutableJWKSet<>(new JWKSet(rsaKey));
}
Configuring a Client to Use This Server
On the resource server side:
# Resource Server application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com # Your authorization server URL
On the client (Spring Boot OAuth2 client):
spring:
security:
oauth2:
client:
registration:
my-auth-server:
client-id: web-client
client-secret: web-secret
authorization-grant-type: authorization_code
scope: openid, profile, email
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
my-auth-server:
issuer-uri: https://auth.example.com
Summary
- Spring Authorization Server implements OAuth 2.1 + OIDC as a Spring Boot application.
- Configure
SecurityFilterChainwithOAuth2AuthorizationServerConfiguration.applyDefaultSecurity()for the auth server endpoints, plus a separate chain for user login (form login). RegisteredClientRepositorydefines which clients can request tokens — useJdbcRegisteredClientRepositoryfor production.- Use
OAuth2TokenCustomizer<JwtEncodingContext>to add custom claims (userId, roles, email) to access and ID tokens. - Persist RSA key pairs (PKCS12 keystore) so tokens don’t become invalid on restart.
- Resource servers configure
issuer-uripointing to this server — Spring auto-downloads the JWKS for validation.
Next: Article 16 covers HTTP authorization — requestMatchers, path patterns, role and authority checks, and the deny-by-default principle.