Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Advanced6 min readUpdated
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.
  • requireProofKey should 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

AuthorizationServerConfig.java
@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

ClientRegistration.java
@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

Persistence.java
@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

JwkConfig.java
@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));
}
Publish both keys, switch signing, wait for old tokens to expire, then remove. No coordinated deploy at any point.

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

TokenCustomizer.java
@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?
Only if you have a reason a managed provider cannot meet — an unusual flow, data residency, or deep integration with an existing identity store. Keycloak, Auth0, Okta and Cognito handle key rotation, MFA, account recovery and compliance for you. Running your own means owning all of that, forever.
How do I rotate signing keys without breaking tokens?
Publish both keys in the JWKS with distinct kid values, sign new tokens with the new key, and keep the old one published until every token signed with it has expired. Resource servers look up by kid and refresh their cached key set, so no coordinated deploy is needed.
Where do I add custom claims?
An OAuth2TokenCustomizer bean. Keep the payload small — a JWT travels on every request, and large tokens hit header size limits at proxies. Put a stable user identifier and coarse roles in; look up anything detailed at the resource server.

Related tutorials