Spring Authorization Server
Running your own OAuth 2.1 and OIDC provider: registering clients, persisting authorizations, JWK sources and key rotation, custom claims, and the consent page.
On this page
Spring Authorization Server is a full OAuth 2.1 and OpenID Connect provider you run yourself. It is the right choice in a narrow set of circumstances and a substantial ongoing commitment in all of them.
Key Takeaways
- Registered clients define which flows and redirect URIs are permitted — the security perimeter.
requireProofKeyshould be true for every client, per OAuth 2.1.- The JWKS endpoint is what makes key rotation possible without coordinated deploys.
- Persist authorizations in a database; the in-memory default loses every session on restart.
- Keep custom claims small — tokens travel on every request.
Minimal configuration
@Configuration
public class AuthorizationServerConfig {
@Bean
@Order(1)
SecurityFilterChain authorizationServer(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // enables /userinfo and discovery
http.exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)));
return http.build();
}
@Bean
AuthorizationServerSettings settings() {
return AuthorizationServerSettings.builder()
// Must match exactly what resource servers validate as `iss`,
// including scheme and any trailing path.
.issuer("https://auth.acme.com")
.build();
}
}The issuer is worth getting right the first time. It appears in every token's iss claim and in the
discovery document, and resource servers reject tokens whose issuer does not match exactly. Changing
it later invalidates every token in flight and every cached discovery document.
Registering clients
@Bean
RegisteredClientRepository registeredClients(JdbcTemplate jdbc, PasswordEncoder encoder) {
RegisteredClient webApp = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("acme-web")
.clientSecret(encoder.encode(System.getenv("WEB_CLIENT_SECRET")))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
// Exact match only. Wildcard redirect URIs enable open-redirect
// attacks that leak authorization codes.
.redirectUri("https://app.acme.com/login/oauth2/code/acme")
.postLogoutRedirectUri("https://app.acme.com/")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("orders.read")
.scope("orders.write")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.requireProofKey(true) // PKCE, mandatory in OAuth 2.1
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(15))
.refreshTokenTimeToLive(Duration.ofDays(7))
// Each use issues a new refresh token and invalidates the
// old one, which is what makes theft detectable.
.reuseRefreshTokens(false)
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
.build())
.build();
RegisteredClient spa = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("acme-spa")
// A browser app cannot hold a secret. PKCE replaces it.
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://app.acme.com/callback")
.scope(OidcScopes.OPENID)
.clientSettings(ClientSettings.builder().requireProofKey(true).build())
.build();
var repository = new JdbcRegisteredClientRepository(jdbc);
repository.save(webApp);
repository.save(spa);
return repository;
}Two settings carry most of the security weight. Exact redirect URI matching — a wildcard or a
prefix match lets an attacker who controls any path on your domain receive authorization codes.
And requireProofKey(true) on every client, including confidential ones, which OAuth 2.1 makes
mandatory precisely because the alternative kept being exploited.
Note what this bean does on every startup, though: it saves both clients again.
JdbcRegisteredClientRepository.save upserts by id, so a changed secret in the environment quietly
takes effect on the next restart, and a client you delete from this method stays registered in the
database forever. That is acceptable as a bootstrap and poor as a management strategy. Beyond a handful
of clients, seed through migrations and manage the rest behind an admin path, so registering a client
carries the same review trail as any other privileged change.
Persistence
@Bean
OAuth2AuthorizationService authorizationService(JdbcTemplate jdbc,
RegisteredClientRepository clients) {
// The in-memory default loses every active authorization on restart, so a
// deploy logs everyone out and invalidates every refresh token.
return new JdbcOAuth2AuthorizationService(jdbc, clients);
}
@Bean
OAuth2AuthorizationConsentService consentService(JdbcTemplate jdbc,
RegisteredClientRepository clients) {
return new JdbcOAuth2AuthorizationConsentService(jdbc, clients);
}Spring ships the schema; apply it with Flyway. This is the single most important production change from the getting-started configuration — an authorization server that forgets everything on restart is not one you can deploy.
Keys and rotation
@Bean
JWKSource<SecurityContext> jwkSource(KeyStoreProperties props) throws Exception {
// Load from a keystore or a secret manager. A key generated at startup
// means every restart invalidates every issued token, and two instances
// sign with different keys.
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (var in = Files.newInputStream(props.path())) {
keyStore.load(in, props.password().toCharArray());
}
List<JWK> keys = new ArrayList<>();
for (String alias : props.activeAliases()) {
var certificate = (X509Certificate) keyStore.getCertificate(alias);
var privateKey = (RSAPrivateKey) keyStore.getKey(alias, props.password().toCharArray());
keys.add(new RSAKey.Builder((RSAPublicKey) certificate.getPublicKey())
.privateKey(privateKey)
.keyID(alias) // becomes the `kid` in the token header
.keyUse(KeyUse.SIGNATURE)
.algorithm(JWSAlgorithm.RS256)
.build());
}
// Every published key can verify; the first is used to sign. Publishing
// the outgoing key alongside the new one is what makes rotation seamless.
return new ImmutableJWKSet<>(new JWKSet(keys));
}Resource servers cache the JWKS and look up by kid, so they pick up a new key on their next refresh.
Handle an unknown kid by refetching once before rejecting, or a rotation between cache refreshes
causes a burst of spurious 401s.
Custom claims
@Bean
OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(UserService users) {
return context -> {
if (!OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) return;
var principal = context.getPrincipal();
var user = users.findByUsername(principal.getName());
context.getClaims().claims(claims -> {
claims.put("tenant_id", user.tenantId());
claims.put("roles", user.roles().stream().map(Enum::name).toList());
// Keep it small. A JWT travels on every request, and proxies
// commonly cap headers at 8KB — a fat token breaks at the edge.
});
};
}The temptation is to put everything a resource server might need into the token to avoid a lookup. Resist it: tokens are sent on every single request, they are visible to anyone holding them, and they cannot be updated until they expire. Put a stable identifier and coarse authorisation data in; look up the rest.
Two endpoints are easy to overlook because nothing visibly breaks without them. /oauth2/revoke and
/oauth2/introspect come with the default configuration, and revocation in particular is what makes
refresh token rotation worth having — a client that detects a stolen token needs somewhere to report
it, otherwise detection changes nothing.
If requireAuthorizationConsent is on, customise the consent screen sooner rather than later. The
default works and is entirely unbranded, and this is the one page in the flow where a user is asked to
make a security decision. An unfamiliar-looking page at that moment teaches people to click through
exactly the prompt that should give them pause.
Should you run one?
The honest answer for most teams is no. Keycloak gives you the same protocol surface plus MFA, account recovery, social identity brokering, LDAP federation, an admin console and a security team maintaining it. Auth0, Okta and Cognito remove the operational burden entirely.
Spring Authorization Server earns its place when you need a flow or a claim structure a product cannot express, when data residency rules prevent a hosted provider, when it must integrate deeply with an existing user store, or when licensing at your user count is prohibitive.
If you do run one, budget for the parts that are not in this guide: account recovery, MFA enrolment, session management, brute-force protection, audit logging, and a security review of all of it. Those are the majority of the work.
What to take away
Set the issuer once and correctly, match redirect URIs exactly, and require PKCE for every client. Persist authorizations in a database or a restart logs everyone out. Load signing keys from a keystore and rotate by publishing both. And weigh honestly whether a managed provider would serve you better.
Frequently Asked Questions
Should I run my own authorization server?
How do I rotate signing keys without breaking tokens?
Where do I add custom claims?
Related tutorials
- OAuth 2.0 — The Complete GuideOAuth 2.0 without the confusion: the four actors, the grants that still matter, why PKCE is mandatory, refresh token rotation, and what OAuth 2.1 removed.
- OAuth 2.0 Resource ServerValidating tokens correctly: NimbusJwtDecoder configuration, issuer and audience validators, mapping claims to authorities, opaque token introspection and multi-tenant decoding.
- JWT Authentication Deep DiveJWTs done safely: structure and claims, why RS256 beats HS256, key rotation with JWKS, the alg=none and key-confusion attacks, and how to revoke a stateless token.
- OpenID Connect (OIDC)The identity layer on OAuth 2.0: what an ID token is and how to validate it, standard scopes and claims, discovery, and single logout across relying parties.