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)

EndpointPurpose
GET /oauth2/authorizeStart authorization flow (redirect users here)
POST /oauth2/tokenExchange code/credentials for tokens
POST /oauth2/introspectValidate opaque tokens
POST /oauth2/revokeRevoke access/refresh tokens
GET /userinfoReturn user profile (OIDC)
GET /.well-known/openid-configurationDiscovery document
GET /.well-known/jwks.jsonPublic 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());
            }
        }
    };
}

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 SecurityFilterChain with OAuth2AuthorizationServerConfiguration.applyDefaultSecurity() for the auth server endpoints, plus a separate chain for user login (form login).
  • RegisteredClientRepository defines which clients can request tokens — use JdbcRegisteredClientRepository for 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-uri pointing 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.