Skip to main content

Command Palette

Search for a command to run...

Techniques to Understand Spring Authorization Server Authentication—from Browser Redirects to Token Signing

Techniques to Understand Spring Authorization Server Authentication—from Browser Redirects to Token Signing Below is a clear, end-to-end explanation of how Spring Authorization Server (SAS) authenticates a user and a client, issues tokens, and pr...

Published
10 min read
Techniques to Understand Spring Authorization Server Authentication—from Browser Redirects to Token Signing
T

I am Tuanh.net. As of 2024, I have accumulated 8 years of experience in backend programming. I am delighted to connect and share my knowledge with everyone.

1. What actually happens when “Login with X” bounces you around?

At a high level, SAS orchestrates three conversations at once: the browser vs. the authorization endpoint, the client vs. the token endpoint, and everyone vs. the key material used to sign tokens. While the OAuth spec gets all the attention, most of the “Spring” magic is in the security filter chains and authentication providers that convert HTTP requests into typed Authentication objects and back again.

Under the hood, Spring Authorization Server wires a dedicated Authorization Server security filter chain that matches well-known endpoints like /oauth2/authorize, /oauth2/token, /oauth2/jwks, /oauth2/introspection, etc. Within that chain, it installs a suite of AuthenticationProviders that know how to:

  • parse and verify client authentication (client secret, private key JWT, mTLS),
  • enforce PKCE and consent on the authorization request,
  • mint tokens in the token endpoint (authorization code, refresh token, client credentials, device code),
  • publish JWKs so resource servers can verify JWT signatures offline.

This means the browser never sees your access token; the browser carries an authorization code, and only the back-channel (server-to-server) exchanges that code for tokens.

1.1 The minimal working server you can actually run

Below is a minimal Spring Boot 3.x app using Spring Authorization Server. It registers one public client (PKCE), one confidential client (client secret), configures in-memory user auth, generates an RSA JWK for signing, and exposes all the right endpoints.

// build.gradle (or use Maven equivalents)
// dependencies {
// implementation("org.springframework.boot:spring-boot-starter-web")
// implementation("org.springframework.boot:spring-boot-starter-oauth2-authorization-server")
// implementation("org.springframework.boot:spring-boot-starter-security")
// implementation("org.springframework.boot:spring-boot-starter-thymeleaf") // for default login
// implementation("com.nimbusds:nimbus-jose-jwt")
// }

// src/main/java/com/example/authserver/SecurityConfig.java
package com.example.authserver;

import com.nimbusds.jose.jwk.;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.web.SecurityFilterChain;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;

@Configuration
public class SecurityConfig {

// 1) The dedicated chain that serves /oauth2/
endpoints
@Bean
SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
// Optional: enable OpenID Connect endpoints
http.getConfigurer(OAuth2AuthorizationServerConfiguration.class)
.oidc(Customizer.withDefaults());
// JWT resource server support for UserInfo endpoint if needed
http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
return http.build();
}

// 2) The app's own login form / session for resource owner authentication
@Bean
SecurityFilterChain appSecurity(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/assets/**").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}

// 3) Two example OAuth clients (public PKCE + confidential secret-based)
@Bean
RegisteredClientRepository registeredClientRepository(PasswordEncoder encoder) {
RegisteredClient publicPkceClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("toolbox-pkce")
.clientAuthenticationMethod(auth -> {}) // public; no client secret
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:8081/login/oauth2/code/toolbox")
.scope(OidcScopes.OPENID)
.scope("profile.read")
.clientSettings(settings -> settings.requireProofKey(true).requireAuthorizationConsent(true))
.build();

RegisteredClient confidentialClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("service-client")
.clientSecret(encoder.encode("service-secret-123"))
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("reports.read")
.build();

return new InMemoryRegisteredClientRepository(publicPkceClient, confidentialClient);
}

// 4) Local in-memory users for the login screen
@Bean
UserDetailsService userDetailsService(PasswordEncoder encoder) {
return username -> User.withUsername("anh")
.password(encoder.encode("password"))
.roles("USER")
.build();
}

@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

// 5) JWK source for signing JWT access tokens and ID tokens
@Bean
JWKSource<securitycontext> jwkSource() {
RSAKey rsaKey = generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}

// 6) Decoder for resource server validations (UserInfo, etc.)
@Bean
JwtDecoder jwtDecoder(JWKSource<securitycontext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}

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

private static KeyPair generateRsaKey() {
try {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
return kpg.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}

How to play with it: run on :8080. A PKCE client running at :8081 redirects to http://localhost:8080/oauth2/authorize?... where you’ll authenticate as anh/password. After consent, the client exchanges the code for tokens at /oauth2/token. The server publishes keys at /oauth2/jwks so resource servers can verify signatures.

1.2 The request morphs: from URL params to strongly-typed Authentication

When your browser hits /oauth2/authorize, the AuthorizationEndpointFilter parses the query string into an OAuth2AuthorizationCodeRequestAuthenticationToken. That flows into OAuth2AuthorizationCodeRequestAuthenticationProvider. If the resource owner hasn’t logged in, Spring Security kicks in with the application filter chain to show the login page. Once you authenticate, SAS resumes the pending authorization with the now-authenticated Principal. If requireAuthorizationConsent is true, the ConsentPage is rendered; otherwise, it proceeds to issue an authorization code and redirects the browser back to the client’s redirect_uri.

At the token endpoint, the HTTP form body becomes an OAuth2AuthorizationCodeAuthenticationToken. The provider verifies:

  • the client’s identity (secret, private key JWT, or nothing for public clients),
  • the authorization code is valid and bound to the same client and redirect_uri,
  • PKCE’s code_verifier matches the original code_challenge.

If all green, it mints tokens and records them in the OAuth2AuthorizationService.

1.3 Where do JWTs actually come from?

SAS delegates token materialization to a JwtEncoder fed by your JWKSource. That encoder signs the token using the private RSA key you generated, and the server publishes the public key via /oauth2/jwks. The claims are shaped by OAuth2TokenCustomizer if you provide one. For example, you can add tenant_id, roles, or a nonce for OIDC ID tokens, as shown below:

// src/main/java/com/example/authserver/JwtCustomizerConfig.java
package com.example.authserver;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;

@Configuration
public class JwtCustomizerConfig {

@Bean
OAuth2TokenCustomizer<jwtencodingcontext> tokenCustomizer() {
return context -> {
if (context.getTokenType().getValue().equals("access_token")) {
context.getClaims().claim("tenant_id", "klara-tech")
.claim("roles", new String[]{"USER", "REPORT_VIEWER"});
}
if (context.getTokenType().getValue().equals("id_token")) {
context.getClaims().claim("ui_theme", "dark");
}
};
}
}

2. How Spring’s filter chains and providers choreograph the dance

The simplest way to grok SAS is to read it as “Spring Security with a pre-wired set of endpoints and providers.” The first filter chain (auth server) is scoped to OAuth endpoints and contains the OAuth/OIDC providers. The second chain is your app’s regular login and pages.

Under the hood, the following happens for the Authorization Code grant:

  1. Client → /oauth2/authorize with client_id, redirect_uri, scope, state, and code_challenge (PKCE).
  2. Server → Login form because resource owner is anonymous.
  3. User authenticates (session or remember-me cookie from your app’s chain).
  4. Server → Consent page if required; otherwise it skips.
  5. Server → Redirect back to client with ?code=...&state=....
  6. Client → /oauth2/token (back channel) with grant_type=authorization_code, code, redirect_uri, and code_verifier (PKCE).
  7. Server mints tokens (access token, refresh token, optional ID token).
  8. Server publishes JWKs at /oauth2/jwks; resource servers cache this to verify signatures.

A compact mental picture: authorize endpoint binds user → client → scopes → code; token endpoint binds client → code → tokens; JWKs bind signature → public verification.

2.1 Client authentication: secret, private key JWT, and mTLS

SAS supports multiple client auth methods. In your RegisteredClient, you choose:

  • Secret: clientAuthenticationMethod=client_secret_basic or post.
  • Public PKCE: no secret, requireProofKey(true)—safer for SPAs and native apps.
  • Private key JWT: the client signs a client_assertion JWT with its private key.
  • mTLS: mutual TLS binds the client identity to its certificate.

The provider verifies the method you declared. If the method is wrong (e.g., secret presented for a public client), the token request fails immediately with invalid_client. The boring answer is also the correct answer: configure your client exactly as it will authenticate, then SAS will do the rest.

Consent exists to make scope escalation visible. When requireAuthorizationConsent(true), SAS persists the authorized scopes per user and client. Requesting a subset next time usually skips consent; requesting new scopes brings the consent UI back. Downstream, you should map scopes to claims in your JWT customizer or in your resource servers’ authorization rules—for example, hasAuthority("SCOPE_profile.read") on a controller.

3. Deep dive: Token lifetimes, refresh, introspection, revocation

Tokens aren’t forever. SAS configures default lifetimes (you can override with TokenSettings). Short access tokens are good hygiene; refresh tokens renew sessions without re-prompting the user, provided the client is confidential or a trusted public app with rotation.

  • Introspection (/oauth2/introspection) lets internal services verify opaque tokens. With JWTs you rarely need this, but it’s vital when you reject self-contained tokens in favor of references.
  • Revocation (/oauth2/revoke) is how clients voluntarily discard refresh or access tokens. For security events, consider invalidating the authorization in the OAuth2AuthorizationService store.

A production-ready stack often mixes: JWT access tokens for public resource servers (fast, cacheable) and introspection for sensitive internal APIs that must reflect near real-time revocation.

4. Resource Server verification—trust but verify

Your resource servers don’t “call back” to the auth server when the token is a JWT; they verify the signature using the JWKs. That’s why publishing /oauth2/jwks matters. A typical resource server config:

// In a downstream API
@EnableWebSecurity
class ApiSecurity {

@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().hasAuthority("SCOPE_profile.read"))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}

Point spring.security.oauth2.resourceserver.jwt.jwk-set-uri to the auth server’s JWK set. The API never sees a client secret; it sees a signed claim set and decides access from scopes/claims.

5. OpenID Connect layer—ID tokens and the /userinfo endpoint

When you enable OIDC (.oidc(Customizer.withDefaults())), SAS exposes an ID token and endpoints like /.well-known/openid-configuration and /userinfo. The ID token is about authentication (who the user is, when they authenticated, and for which client), while the access token is about authorization. If you’re building login for a web app, you probably want OIDC on.

Customize OIDC claims with the same OAuth2TokenCustomizer , or by plugging an OidcUserInfoMapper to determine what /userinfo returns. Just don’t leak private data; least privilege applies to identity too.

6. Advanced hooks you’ll actually need in real life

  • Persistent stores: swap the in-memory RegisteredClientRepository and OAuth2AuthorizationService for JDBC or JPA. This is non-negotiable in multi-instance deployments.
  • Event listeners: audit who granted what; wire a listener for authorization success/failure.
  • Account linking / multi-tenant claims: populate tenant_id, org_id, plan, and roles reliably from your directory or user DB.
  • Strong client auth: prefer private key JWT or mTLS for service-to-service. Secrets are the plastic forks of security—use them only when you must.

A simple JDBC swap (sketch):

@Bean
RegisteredClientRepository jdbcClients(JdbcTemplate jdbc) {
JdbcRegisteredClientRepository repo = new JdbcRegisteredClientRepository(jdbc);
// bootstrap or migrate clients here...
return repo;
}

@Bean
OAuth2AuthorizationService jdbcAuthorizationService(JdbcTemplate jdbc, RegisteredClientRepository clients) {
return new JdbcOAuth2AuthorizationService(jdbc, clients);
}

7. Troubleshooting like a pro (a.k.a. reading the right logs)

Turn on debug logging for the relevant packages when flows misbehave:

# application.properties
logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.security.oauth2=DEBUG
logging.level.org.springframework.security.oauth2.server.authorization=DEBUG

Typical culprits:

  • Redirect URI mismatch → The exact string (scheme, host, port, path) must match the registered value.
  • Invalid PKCEcode_verifier missing or doesn’t match code_challenge.
  • Wrong client auth method → Public client trying to use a secret, or vice versa.
  • Clock skew → Misaligned times between services can break JWT validation (nbf, exp).

8. Security posture—turning a demo into a fortress

Rotate keys periodically and publish multiple JWKs (kid-based rotation). Enforce short lifetimes for access tokens, use refresh token rotation, and monitor unusual token usage. If your threat model includes stolen refresh tokens, require DPoP or mTLS-bound tokens to bind tokens to a client key. Keep consent meaningful; scopes should be small and descriptive, not marketing poetry.

9. The “why” behind the “what”

The design is intentionally split: authorize with users in the loop; tokenize with servers only. That boundary prevents tokens from riding user agents and lets you apply harder checks (client auth, PKCE, replay protection) where they matter. JWTs combined with JWKs lower runtime coupling—APIs don’t need to “phone home” on every call—while still letting you revoke or introspect when necessary.

10. Where to go next (and what to harden)

Add private key JWT client authentication for service clients, introduce multitenant claim population, and move the client and authorization stores to a durable DB. Add health checks for /oauth2/jwks and key rotation alarms. If you expose OIDC, curate the ID token and /userinfo payloads so they remain minimal and stable—clients love stability almost as much as they love access tokens.

Final words

If identity is the new perimeter, Spring Authorization Server is the drawbridge mechanism—and you just learned how the chains, pulleys, and counterweights all fit together. If anything above was foggy, confusing, or you want a deeper dive into a specific grant (device code? token exchange? mTLS?), drop a comment below and I’ll tackle it.

Read more at : Techniques to Understand Spring Authorization Server Authentication—from Browser Redirects to Token Signing

More from this blog

T

tuanh.net

540 posts

Are you ready to elevate your Java, OOP, Spring, and DevOps skills? Look no further!