Spring Authorization Server in Action: Securing Modern Applications with OAuth 2.1

In this post, we’ll explore a complete implementation of the Spring authorization server using PostgreSQL for data persistence.


Prerequisites

  • Java 25
  • Spring Boot 4.0.0
  • Maven 3.9+
  • Docker with Compose v2
  • curl ,jq and Postman for testing

Overview

Every team eventually builds their own auth. A session table here, a JWT library there, a custom token endpoint someone wrote in a weekend — and six months later, no one fully understands it.

Spring Authorization Server gives you a standards-compliant OAuth 2.1 / OpenID Connect 1.0 authorization server that you own and operate, backed by the full Spring Security ecosystem. It is not a cloud service. It is not Keycloak. It is the protocol implemented directly in your application, with PostgreSQL persistence, token customization, and PKCE built in.

This article walks through a complete implementation: one authorization server with JDBC persistence, one resource server that validates JWT tokens, and two registered clients covering the two flows that matter most in 2025 — Authorization Code with PKCE and Client Credentials.

OAuth 2.1 vs OAuth 2.0 — What Actually Changed

OAuth 2.1 is not a breaking rewrite. It consolidates the best practices that had accumulated as mandatory guidance on top of 2.0 into the spec itself. The practical differences:

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. Even confidential clients (those that can store a secret) must use PKCE with Spring Authorization Server when requireProofKey(true) is set.

The Implicit flow is gone because it returned tokens in the browser fragment — exposed to JavaScript, browser history, and referrer headers. Authorization Code with PKCE replaces it completely for SPA and mobile clients.

Architecture

┌─────────────────────────────────────────────────────────┐
│                    Authorization Server                 │
│                     (auth-server:9000)                  │
│                                                         │
│  /oauth2/authorize    OAuth 2.1 Authorization Endpoint  │
│  /oauth2/token        Token Endpoint                    │
│  /oauth2/jwks         JWK Set (public keys)             │
│  /oauth2/revoke       Token Revocation                  │
│  /oauth2/introspect   Token Introspection               │
│  /userinfo            OIDC UserInfo Endpoint            │
│  /.well-known/        OIDC Discovery Document           │
│                                                         │
│  PostgreSQL: oauth2_registered_client                   │
│              oauth2_authorization                       │
│              oauth2_authorization_consent               │
│              users / authorities                        │
└───────────────────────┬─────────────────────────────────┘
                        │ issues JWT tokens (RS256)
                        │
         ┌──────────────▼──────────────┐
         │       Resource Server        │
         │     (resource-server:8080)   │
         │                              │
         │  Fetches JWK Set on startup  │
         │  Validates JWT signatures    │
         │  Enforces scope claims       │
         │  /api/v1/articles            │
         └──────────────────────────────┘

Two client types:

web-client — Authorization Code + PKCE + Refresh Token. Used by browser applications and native apps. Requires user authentication and consent.

m2m-client — Client Credentials. Used by backend services. No user involved — the service authenticates directly with its client_id and client_secret.

Database Schema

Spring Authorization Server ships its own JDBC-ready table definitions. The PostgreSQL version uses TEXT for columns that MySQL stores as BLOB.

ERD (relevant tables):

┌─────────────────────────────────┐
│ oauth2_registered_client │
├─────────────────────────────────┤
│ id (PK) │
│ client_id (UNIQUE) │
│ client_secret │
│ authorization_grant_types │
│ redirect_uris │
│ scopes │
│ client_settings (JSON) │
│ token_settings (JSON) │
└─────────────────┬───────────────┘
│ 1:N
┌─────────────────▼───────────────┐
│ oauth2_authorization │
├─────────────────────────────────┤
│ id (PK) │
│ registered_client_id (FK-ish) │
│ principal_name │
│ access_token_value (TEXT) │
│ refresh_token_value (TEXT) │
│ authorization_code_value (TEXT) │
└─────────────────────────────────┘

┌─────────────────────────────────┐
│ oauth2_authorization_consent │
├─────────────────────────────────┤
│ registered_client_id (PK) │
│ principal_name (PK) │
│ authorities (approved scopes) │
└─────────────────────────────────┘

The oauth2_authorization table stores all token state. A single row holds the authorization code (short-lived), the access token, and the refresh token for one grant. When a token is revoked or expires, the row is updated (the value becomes null), not deleted. Cleanup of expired rows is the responsibility of a scheduled job — Spring Authorization Server does not auto-purge.

client_settings and token_settings are stored as JSON blobs. Spring Authorization Server serializes them using Jackson internally.

-- oauth2_registered_client
CREATE TABLE IF NOT EXISTS oauth2_registered_client (
id VARCHAR(100) NOT NULL,
client_id VARCHAR(100) NOT NULL,
client_id_issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
client_secret VARCHAR(200) DEFAULT NULL,
client_secret_expires_at TIMESTAMP DEFAULT NULL,
client_name VARCHAR(200) NOT NULL,
client_authentication_methods VARCHAR(1000) NOT NULL,
authorization_grant_types VARCHAR(1000) NOT NULL,
redirect_uris VARCHAR(1000) DEFAULT NULL,
post_logout_redirect_uris VARCHAR(1000) DEFAULT NULL,
scopes VARCHAR(1000) NOT NULL,
client_settings VARCHAR(2000) NOT NULL,
token_settings VARCHAR(2000) NOT NULL,
PRIMARY KEY (id)
);

-- oauth2_authorization (TEXT instead of BLOB for PostgreSQL)
CREATE TABLE IF NOT EXISTS oauth2_authorization (
id VARCHAR(100) NOT NULL,
registered_client_id VARCHAR(100) NOT NULL,
principal_name VARCHAR(200) NOT NULL,
authorization_grant_type VARCHAR(100) NOT NULL,
authorized_scopes VARCHAR(1000) DEFAULT NULL,
attributes TEXT DEFAULT NULL,
state VARCHAR(500) DEFAULT NULL,
authorization_code_value TEXT DEFAULT NULL,
access_token_value TEXT DEFAULT NULL,
-- ... (full schema in source repo)
refresh_token_value TEXT DEFAULT NULL,
PRIMARY KEY (id)
);

Full schema is in auth-server/src/main/resources/schema.sql in the source code.

Let’s code

Step 1: Authorization Server — pom.xml

Let’s create a simple Spring Boot project from start.spring.io,

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

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

<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>

spring-security-oauth2-authorization-server is managed by Spring Boot’s BOM — no version needed. It pulls in Nimbus JOSE JWT for JWK operations and Spring Security for the filter chain.

Step 2: JWK Source — Signing Keys

JWT tokens are signed with RSA private keys and verified by resource servers using the corresponding public key fetched from /oauth2/jwks.

public final class Jwks {

public static RSAKey generateRsa() {
KeyPair keyPair = generateRsaKeyPair();
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 generateRsaKeyPair() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException(ex);
}
}
}
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = Jwks.generateRsa();
return new ImmutableJWKSet<>(new JWKSet(rsaKey));
}

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

Production note: These keys are generated in memory on every startup. A new kid (key ID) is issued each time, invalidating all previously issued tokens on restart. For production:

  • Store the RSA key in a secrets manager (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager)
  • Load it at startup via @Value("${app.security.rsa-private-key}")
  • Use a stable kid so old tokens remain verifiable until expiry
// Production-oriented key loading from application.yml
@Value("${app.security.rsa-private-key}")
RSAPrivateKey privateKey;

@Value("${app.security.rsa-public-key}")
RSAPublicKey publicKey;

@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID("auth-server-key-v1") // stable kid
.build();
return new ImmutableJWKSet<>(new JWKSet(rsaKey));
}

Step 3: Authorization Server Security Filter Chain

@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {

OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // adds /userinfo and /.well-known/openid-configuration

http
.exceptionHandling(exceptions -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
// The auth server validates its own JWTs for the /userinfo endpoint
.oauth2ResourceServer(resourceServer -> resourceServer
.jwt(Customizer.withDefaults()));

return http.build();
}

applyDefaultSecurity wires all the OAuth 2.1 endpoint handlers. Without @Order(1), Spring Security applies the default filter chain to these endpoints and you get 403 instead of token responses.

The defaultAuthenticationEntryPointFor check is critical: when a browser navigates to /oauth2/authorize without a session, it must receive a redirect to /login, not a 401 JSON response. The MediaTypeRequestMatcher for text/html distinguishes browser requests from API requests.

.oauth2ResourceServer(...) on the authorization server chain is needed for the /userinfo endpoint — it accepts a Bearer token and returns OIDC user claims.

Step 4: AuthorizationServerSettings + JDBC Repositories

@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("http://localhost:9000")
.build();
}

@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);
}

The issuer URI in AuthorizationServerSettings populates the iss claim in every token and appears in the OIDC discovery document. Resource servers configured with spring.security.oauth2.resourceserver.jwt.issuer-uri will fetch the discovery document and validate the iss claim against this exact string. A mismatch between the configured issuer and the one in the token causes silent token rejection.

Step 5: Default Security Filter Chain (Login)

@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}

@Bean
public JdbcUserDetailsManager userDetailsManager(DataSource dataSource) {
return new JdbcUserDetailsManager(dataSource);
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

JdbcUserDetailsManager reads from the users and authorities tables. Spring Security creates them automatically when you call schema.sql initialization. The form login at /login is the entry point for Authorization Code flows — users authenticate here before being redirected back to the client with an authorization code.

Step 6: Client Registration

Clients are registered programmatically via DataInitializer on startup:

// web-client: Authorization Code + PKCE (browser/SPA/mobile)
RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("web-client")
.clientSecret(passwordEncoder.encode("web-secret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/web-client")
.redirectUri("https://oauthdebugger.com/debug")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("articles:read")
.scope("articles:write")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.requireProofKey(true) // PKCE required — OAuth 2.1 mandate
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofHours(1))
.refreshTokenTimeToLive(Duration.ofDays(30))
.reuseRefreshTokens(false) // Rotate on every use
.build())
.build();
clientRepository.save(webClient);

// m2m-client: Client Credentials (service-to-service, no user)
RegisteredClient m2mClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("m2m-client")
.clientSecret(passwordEncoder.encode("m2m-secret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("articles:read")
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(30))
.build())
.build();
clientRepository.save(m2mClient);

requireProofKey(true) enforces PKCE at the client level. Attempts to exchange an authorization code without a valid code_verifier are rejected with invalid_grant.

reuseRefreshTokens(false) enables refresh token rotation — every /oauth2/token refresh request issues a new refresh token and invalidates the previous one. This is required by OAuth 2.1 for public clients and strongly recommended for confidential clients.

Step 7: JWT Token Customization

The default access token contains sub, iss, iat, exp, scope, and aud. Add user roles and the username so resource servers can make authorization decisions without a UserInfo call:

@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() {
return context -> {
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
Authentication principal = context.getPrincipal();
Set<String> roles = principal.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toUnmodifiableSet());
context.getClaims()
.claim("roles", roles)
.claim("username", principal.getName());
}
};
}

The JwtEncodingContext check for OAuth2TokenType.ACCESS_TOKEN is important. The same OAuth2TokenCustomizer is invoked for ID tokens (OIDC) and access tokens. Adding user roles to an ID token is a spec violation — ID tokens are for identity, not authorization.

Step 8: Resource Server

# resource-server/src/main/resources/application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${AUTH_SERVER_ISSUER_URI:http://localhost:9000}

With issuer-uri, Spring Boot:

  1. Fetches http://localhost:9000/.well-known/openid-configuration on startup
  2. Reads jwks_uri from the discovery document
  3. Fetches the JWK Set and caches the public keys
  4. Validates every incoming JWT: signature, expiry, iss claim, and aud claim
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults()));
return http.build();
}
}

Scope enforcement uses @PreAuthorize:

@GetMapping
@PreAuthorize("hasAuthority('SCOPE_articles:read')")
public List<Article> getAll(@AuthenticationPrincipal Jwt jwt) {
return ARTICLES;
}

@PostMapping
@PreAuthorize("hasAuthority('SCOPE_articles:write')")
public ResponseEntity<Article> create(@RequestBody Article article,
@AuthenticationPrincipal Jwt jwt) {
return ResponseEntity.status(201).body(article);
}

Spring maps scope values from the JWT to SCOPE_<value> authorities. SCOPE_articles:read maps to scope articles:read. Method-level security requires @EnableMethodSecurity — without it, @PreAuthorize annotations are silently ignored.

Testing

1. Start the Stack

docker compose up -d

Wait for PostgreSQL to be healthy, then auth-server, then resource-server.

2. Client Credentials Flow (M2M)

# Get an access token for m2m-client
TOKEN=$(curl -s -X POST http://localhost:9000/oauth2/token \
-u "m2m-client:m2m-secret" \
-d "grant_type=client_credentials&scope=articles:read" \
| jq -r '.access_token')

Decode the token to see its claims:

echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq .

You can use https://www.jwt.io/

Expected payload:

{
"sub": "m2m-client",
"iss": "http://localhost:9000",
"iat": 1753920000,
"exp": 1753921800,
"scope": "articles:read",
"roles": [],
"username": "m2m-client"
}

3. Call the Resource Server

curl -s http://localhost:8080/api/v1/articles \
-H "Authorization: Bearer $TOKEN" | jq .

Expected response:

[
{"id":"1","title":"Spring Authorization Server in Action",...},
{"id":"2","title":"OAuth 2.1 Deep Dive",...},
{"id":"3","title":"Spring Boot 4 Observability",...}
]

4. Test Scope Enforcement

The m2m-client token has articles:read but not articles:write. A POST attempt should return 403:

curl -s -o /dev/null -w "%{http_code}" \
-X POST http://localhost:8080/api/v1/articles \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"4","title":"Test","author":"Test","summary":"Test"}'
# 403

5. Authorization Code Flow

Use https://oauthdebugger.com/debug to test the browser flow without writing a client app:

  1. Open: http://localhost:9000/oauth2/authorize?response_type=code&client_id=web-client&redirect_uri=https://oauthdebugger.com/debug&scope=openid%20profile%20articles:read&code_challenge=<generated>&code_challenge_method=S256
  2. Log in as alice / password
  3. Approve the consent screen
  4. Exchange the authorization code for tokens at /oauth2/token

6. Token Revocation

curl -s -X POST http://localhost:9000/oauth2/revoke \
-u "m2m-client:m2m-secret" \
-d "token=$TOKEN&token_type_hint=access_token"
# 200 OK

# Next call to resource server returns 401
curl -s -o /dev/null -w "%{http_code}" \
http://localhost:8080/api/v1/articles \
-H "Authorization: Bearer $TOKEN"
# 401

Token revocation requires that the resource server calls the /oauth2/introspect endpoint on each request, or that the auth server pushes invalidation events to resource servers. With JWTs validated locally by the resource server (the default), revocation is not reflected until the token expires. This is the fundamental tradeoff of JWT-based validation.

Production Considerations

1. Persistent, stable JWK keys

Generate an RSA key pair once, store it in a secrets manager, and load it at startup. Every auth server instance must use the same key pair (or the JWK Set must contain all active keys). Key rotation means adding a new key to the JWK Set while keeping the old one until all tokens signed with it have expired.

2. HTTPS everywhere

The token endpoint transmits client secrets and tokens in clear text over HTTP in this demo. In production: TLS at the load balancer minimum, and do not expose /oauth2/token on plain HTTP at any layer.

3. Scheduled token cleanup

oauth2_authorization rows accumulate. Expired tokens are not deleted automatically. Add a scheduled job:

@Scheduled(cron = "0 0 2 * * ?")
public void cleanExpiredAuthorizations() {
jdbcTemplate.update("""
DELETE FROM oauth2_authorization
WHERE (access_token_expires_at < NOW()
OR access_token_expires_at IS NULL)
AND (refresh_token_expires_at < NOW()
OR refresh_token_expires_at IS NULL)
""");
}

4. Consent revocation API

Users should be able to revoke a client’s consent. JdbcOAuth2AuthorizationConsentService provides remove(OAuth2AuthorizationConsent consent) — wire it to an admin endpoint.

5. Multiple auth server instances

All instances must share the same PostgreSQL database and the same JWK keys. With ImmutableJWKSet, each instance generates its own keys unless you load them from a shared source. Use the issuer-uri auto-config approach in resource servers — they will tolerate multiple active key IDs as long as all are present in the JWK Set.

Common Pitfalls

Issuer URI mismatch

The most common failure mode. If the auth server advertises http://localhost:9000 but the resource server is configured with http://auth-server:9000, every token validation fails with The iss claim is not valid. The issuer must be identical in both places. In Docker Compose, use service names consistently or use a shared environment variable.

Redirect URI exact match

OAuth 2.1 requires exact redirect URI matching. http://127.0.0.1:8080/callback and http://localhost:8080/callback are different URIs. Register every URI you need, including trailing slashes.

Missing @Order(1) on authorization server filter chain

Without it, Spring Security applies the default filter chain to all requests. OAuth endpoints return 403 or redirect to /login instead of handling token requests. Always annotate the authorization server chain with @Order(1).

Scope prefix confusion

Spring Security prefixes JWT scope values with SCOPE_ when creating authorities. Use SCOPE_articles:read in @PreAuthorize, not articles:read. Forgetting the prefix means @PreAuthorize never grants access, even with a valid token.

reuseRefreshTokens(true) in production

The default is true — the same refresh token is reused until it expires. A stolen refresh token remains valid indefinitely. Set reuseRefreshTokens(false) to rotate on every use.

In-memory key pairs in multi-node deployments

Two auth server instances with different ephemeral keys will issue tokens with different kid values. A resource server that caches the JWK Set from instance A cannot validate a token signed by instance B. Stable, shared keys are non-negotiable in production.

Alternatives

Spring Authorization Server is the right choice when your team is already in the Spring ecosystem, you need custom token claims or flows that no managed provider supports, and you want to avoid the operational footprint of running a full IAM platform like Keycloak.

Key Takeaways

OAuth 2.1 simplifies the choice: PKCE is required for all clients, Implicit flow is gone, and refresh token rotation is mandatory. These defaults are production-safe by design — the insecure options that existed in 2.0 have been removed.

JDBC persistence is the production baseline: InMemoryRegisteredClientRepository and InMemoryOAuth2AuthorizationService are for demos. In any multi-instance or restart-tolerant deployment, use the JDBC variants backed by PostgreSQL.

Issuer URI is the load-bearing configuration: It appears in every issued token and in the discovery document. Mismatches between the auth server configuration and the resource server expectation produce silent failures that are hard to diagnose without debug logging.


The complete source code is available on GitHub.

Support me through GitHub Sponsors.

Thank you for Reading !! See you in the next post.

References

Link to Medium blog

Related Posts