05-spring-security

OAuth2 and OIDC: Resource Server, Client and Auth Server

What the flows actually are, configuring a resource server in two lines, the authorisation code flow with PKCE, and when to self-host an authorisation server.

October 9, 2026
spring-securityoauth2oidcresource-serverpkceauthorization-serverjwtsso

Delegated Authorisation, Not Authentication

OAuth2 is frequently described as "login with Google", which is misleading. OAuth2 is a delegated authorisation framework — a way for an application to obtain limited access to a resource on a user's behalf, without handling their password.

OIDC (OpenID Connect) is the layer on top that adds authentication: an ID token stating who the user is. "Login with Google" is OIDC.

The distinction matters because using OAuth2's access token as proof of identity is a known mistake. An access token says this bearer may call these APIs; it does not reliably say who the bearer is, and it was not issued to your application to make that claim.

The Four Roles

RoleWhoExample
Resource ownerThe userA customer
ClientThe app wanting accessYour SPA or mobile app
Authorisation serverIssues tokensKeycloak, Auth0, Entra ID
Resource serverThe API accepting tokensYour Spring Boot service

A Spring application is usually a resource server, sometimes a client, and occasionally the authorisation server. Spring has separate support for each.

Resource Server

The most common case — your API validates bearer tokens it did not issue:

groovy
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
}
yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/shop
          audiences: shop-api
java
@Bean
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
    return http
        .securityMatcher("/api/**")
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .csrf(CsrfConfigurer::disable)
        .build();
}

That issuer-uri does a surprising amount. At startup Spring fetches /.well-known/openid-configuration, discovers the JWKS endpoint, and configures a validator that checks signature, issuer, audience and expiry — with key rotation and caching handled. This is the five-check list from the authentication guide, implemented correctly, for two lines of YAML.

⚠️

issuer-uri means the application fetches provider metadata at startup, so an unreachable provider delays or fails startup. Use jwk-set-uri instead if you need to avoid that coupling, and consider whether your readiness probe should account for it — a provider outage shouldn't necessarily restart your pods, per the liveness/readiness distinction.

Mapping scopes and claims to authorities

By default, scopes become authorities prefixed SCOPE_:

java
.requestMatchers("/api/orders/**").hasAuthority("SCOPE_orders:read")

Most providers put roles in a custom claim instead, so you convert:

java
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    var authorities = new JwtGrantedAuthoritiesConverter();
    authorities.setAuthorityPrefix("ROLE_");
    authorities.setAuthoritiesClaimName("roles");       // provider-specific
 
    var converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(authorities);
    return converter;
}

Now hasRole("ADMIN") works against the provider's roles claim — reconnecting to the role/authority conventions from the previous guide. Keycloak nests roles under realm_access.roles, which needs a custom converter; check your provider's actual token rather than assuming.

Opaque tokens

Some providers issue reference tokens with no readable content. Validation then means asking the provider:

yaml
spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: https://auth.example.com/oauth2/introspect
          client-id: shop-api
          client-secret: ${INTROSPECTION_SECRET}

The trade is explicit: a network call per request (cache it), in exchange for immediate revocation — the opposite of the JWT trade-off. For a service where a revoked token must stop working at once, that cost may be worth paying.

Client

When your application needs to act on a user's behalf — or simply offer "log in with Google":

groovy
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}
yaml
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid,profile,email
          inventory:
            client-id: shop-service
            client-secret: ${INVENTORY_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            scope: inventory:read,inventory:write
        provider:
          inventory:
            token-uri: https://auth.example.com/oauth2/token
java
http.oauth2Login(Customizer.withDefaults());     // adds the full login flow

Spring provides the redirect endpoints, callback handling, token exchange and session creation.

The authorisation code flow with PKCE

The flow worth understanding, because every browser and mobile login uses it:

PKCE (Proof Key for Code Exchange) is what makes this safe for clients that can't keep a secret. The client generates a random code_verifier, sends its hash as code_challenge on the way out, and presents the original verifier when redeeming the code. An attacker who intercepts the authorisation code cannot use it without the verifier.

PKCE began as a mobile-specific measure and is now recommended for all clients, confidential ones included. Spring Security enables it automatically for public clients; enable it explicitly otherwise.

🚨

The implicit flow and the resource owner password credentials flow are both deprecated by the OAuth 2.0 Security Best Current Practice, and OAuth 2.1 removes them. Implicit returns tokens in the URL fragment, where they land in browser history and referrer headers. Password grant requires your application to handle the user's actual password, which defeats the purpose of delegation. If a tutorial or an internal service uses either, treat it as a finding. Authorisation code + PKCE replaces both.

Service-to-service: client credentials

No user involved — one service authenticating as itself:

java
@Bean
RestClient inventoryClient(RestClient.Builder builder,
                           OAuth2AuthorizedClientManager clients) {
    return builder
            .baseUrl("https://inventory.internal")
            .requestInterceptor(new OAuth2ClientHttpRequestInterceptor(clients))
            .build();
}

The interceptor obtains and attaches a token, and refreshes it before expiry. This replaces the pattern of passing a long-lived shared API key between services, with the benefit that the token is short-lived and scoped.

ID Tokens vs Access Tokens

The distinction that causes real vulnerabilities:

ID tokenAccess token
PurposeAuthentication — who the user isAuthorisation — what the bearer may do
AudienceThe client that requested loginThe resource server
Validated byThe clientThe resource server
FormatAlways JWTJWT or opaque
🚨

Never send an ID token to your API as a bearer credential, and never accept one. Its audience is the client, not your API, so accepting it means accepting a token minted for a different party — the audience-validation failure from the authentication guide, arrived at by a different route. An API that accepts ID tokens will accept one issued to any client of that provider, including an attacker's own registered application. Send the access token.

Authorisation Server

Spring Authorization Server lets you be the provider:

groovy
dependencies {
    implementation 'org.springframework.security:spring-security-oauth2-authorization-server'
}
java
@Bean
RegisteredClientRepository registeredClientRepository() {
    RegisteredClient spa = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("shop-spa")
            .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)   // public client
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .redirectUri("https://shop.example.com/callback")
            .scope(OidcScopes.OPENID)
            .scope("orders:read")
            .clientSettings(ClientSettings.builder().requireProofKey(true).build())  // PKCE
            .build();
    return new InMemoryRegisteredClientRepository(spa);
}

It is a full OIDC-certified implementation — authorisation code, client credentials, refresh tokens, PKCE, JWKS, discovery, token introspection and revocation.

When self-hosting makes sense: a regulatory requirement that credentials never leave your infrastructure, an existing user store you must authenticate against, or cost at very large user counts.

When it doesn't: most of the time. An authorisation server is security-critical infrastructure you must patch, monitor, key-rotate, back up and operate correctly. Keycloak (self-hosted, open source) or a managed provider gives you the same protocol with an admin UI, user management, MFA, federation and someone else's on-call rota. Choosing to build this is choosing to own it.

✅

Spring Authorization Server is genuinely useful in testing, regardless of what you run in production. A real authorisation server in a Testcontainer — or Keycloak's image — lets integration tests exercise the actual token flow instead of mocking JwtDecoder, which is where provider-specific claim-mapping bugs surface.

Check yourself

A mobile app authenticates with OIDC and sends the ID token as a Bearer credential to the backend API, which validates the signature and issuer. What is wrong?

Choosing an Approach

SituationApproach
API consumed by your own SPAResource server + provider-issued JWTs
"Log in with Google/GitHub"OAuth2 client with oauth2Login
Service-to-serviceClient credentials grant
Several APIs, one sign-inProvider (Keycloak or managed) + resource servers
Credentials must not leave your estateSpring Authorization Server, knowingly
One small internal appForm login may be entirely sufficient

That last row is worth saying out loud. OAuth2 solves delegation across trust boundaries. An internal tool with fifteen users and no third-party integration gets complexity from it, not security — form login with sessions is less to operate and easier to reason about.

The Mental Model, Restated

  1. OAuth2 is authorisation; OIDC adds authentication. An access token is not proof of identity.
  2. Resource server is two lines of config, and issuer-uri gets the five validation checks right.
  3. Map provider claims to authorities with a JwtAuthenticationConverter.
  4. Opaque tokens trade a network call for immediate revocation.
  5. Authorisation code + PKCE for all interactive clients. Implicit and password grants are deprecated.
  6. Client credentials for service-to-service, replacing shared API keys.
  7. ID token audience is the client; access token audience is the API. Never mix them.
  8. Self-host an authorisation server only with a reason — it's security-critical infrastructure you then own.

What's Next

One guide remains in this phase, covering the two protections that are most often misconfigured by copy-paste: CORS, which is about which origins a browser may call you from, and CSRF, which is about whether a browser can be tricked into calling you. Plus how to test all of it.