OAuth 2.0 and OpenID Connect by Example with Spring Boot 3

1. Overview

OAuth 2.0 and OpenID Connect (OIDC) are often mentioned together, so many people treat them as one. In reality, they solve two different problems. OAuth 2.0 handles authorization (allowing an application to call an API on behalf of a user). OIDC handles authentication (knowing who the user is).

In this article, you’ll understand the Authorization Code + PKCE flow. Then you’ll build two Spring Boot 3 applications: a client that logs in via OIDC and a resource server that validates access tokens as JWT. At the end, there are tests for the resource server and a list of common mistakes.

2. How OAuth 2.0 and OpenID Connect Differ

2.1. The Four Roles in OAuth 2.0

OAuth 2.0 defines four roles:

  • Resource Owner: the user, the owner of the data (for example, a customer with orders).
  • Client: the application that wants to access data (for example, the web app shop-web).
  • Authorization Server: where the user logs in, grants permission, and receives a token. Examples: Keycloak, Okta, Auth0.
  • Resource Server: the API that holds the data (for example, order-api).

The result of OAuth 2.0 is an access token. This token is meant for the resource server, not for the client. The client merely holds and sends it. It should not read the token to infer who the user is.

2.2. What OIDC Adds

OIDC is a layer built on top of OAuth 2.0. When the client requests the openid scope, the authorization server returns an additional ID token. This is always a JWT, cryptographically signed, and contains authentication information:

{
  "iss": "http://localhost:8180/realms/shop",
  "sub": "customer-42",
  "aud": "shop-web",
  "exp": 1760353200,
  "iat": 1760352900,
  "nonce": "n-0S6_WzA2Mj",
  "email": "lan@example.com",
  "name": "Nguyễn Thị Lan"
}

Key claims include:

  • iss: who issued the token.
  • sub: the unique, stable identifier of the user.
  • aud: which client this token is intended for.
  • nonce: replay protection.

You can summarize it this way:

Access token  -> send to the API (resource server)  -> "what you're allowed to do"
ID token      -> client reads to log in             -> "who the user is"

3. Authorization Code Flow with PKCE

For web applications, SPAs, and mobile apps, you should use the Authorization Code flow, along with PKCE (Proof Key for Code Exchange). Implicit flow and Password grant are now deprecated.

1. User clicks "Log In" on shop-web
2. shop-web generates a random code_verifier and computes code_challenge = SHA256(code_verifier)
3. Redirect the browser to:
   /authorize?response_type=code&client_id=shop-web&scope=openid profile email
             &redirect_uri=...&state=xyz&nonce=abc
             &code_challenge=...&code_challenge_method=S256
4. User logs in at Keycloak; Keycloak redirects back to:
   /login/oauth2/code/shop?code=SplxlOBe&state=xyz
5. shop-web (back-channel) calls POST /token with code + code_verifier + client_secret
6. Keycloak returns: access_token, id_token, refresh_token
7. shop-web validates id_token (signature, iss, aud, exp, nonce) -> creates a session
8. shop-web calls order-api with header: Authorization: Bearer <access_token>

Each parameter prevents a specific type of attack:

  • state prevents CSRF at the callback step.
  • nonce binds the ID token to the correct login session.
  • PKCE ensures that even if someone steals the code, they cannot exchange it for a token without the code_verifier.

The good news is that Spring Security handles nearly all of the above steps for you.

4. Building an OIDC Login Client

4.1. Dependencies and Configuration

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Assume Keycloak runs on port 8180, with a realm shop and a client shop-web:

spring:
  security:
    oauth2:
      client:
        registration:
          shop:
            provider: keycloak
            client-id: shop-web
            client-secret: ${SHOP_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            scope: openid, profile, email, orders.read
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            issuer-uri: http://localhost:8180/realms/shop

You only need to declare issuer-uri. Spring will read /.well-known/openid-configuration to automatically discover the authorize, token, jwks, and end_session endpoints. The openid scope is what transforms an OAuth 2.0 flow into an OIDC login flow.

4.2. Security Configuration

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http,
                                            ClientRegistrationRepository registrations) throws Exception {
        var resolver = new DefaultOAuth2AuthorizationRequestResolver(
                registrations, "/oauth2/authorization");
        resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());

        var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(registrations);
        logoutHandler.setPostLogoutRedirectUri("{baseUrl}");

        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/").permitAll()
                .anyRequest().authenticated())
            .oauth2Login(login -> login
                .authorizationEndpoint(ep -> ep.authorizationRequestResolver(resolver)))
            .logout(logout -> logout.logoutSuccessHandler(logoutHandler));
        return http.build();
    }
}

This configuration does three things:

  • oauth2Login enables the entire flow from section 3, including redirect, callback, code exchange, and ID token validation.
  • withPkce() adds code_challenge even for confidential clients (those with a secret). Public clients already have PKCE enabled by default in Spring.
  • OidcClientInitiatedLogoutSuccessHandler logs out from Keycloak as well (RP-initiated logout). Without it, users clicking “Log In” again will be logged back in immediately without entering their password.

4.3. Reading User Information and Calling APIs

@RestController
public class AccountController {

    private final RestClient orderApi = RestClient.create("http://localhost:8081");

    @GetMapping("/me")
    public Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
        return Map.of(
                "subject", user.getSubject(),
                "email", Objects.requireNonNullElse(user.getEmail(), "(not provided)"),
                "name", Objects.requireNonNullElse(user.getFullName(), "(not provided)"));
    }

    @GetMapping("/my-orders")
    public String myOrders(@RegisteredOAuth2AuthorizedClient("shop") OAuth2AuthorizedClient client) {
        return orderApi.get()
                .uri("/api/orders")
                .headers(h -> h.setBearerAuth(client.getAccessToken().getTokenValue()))
                .retrieve()
                .body(String.class);
    }
}

These two endpoints use two different types of tokens:

  • OidcUser is built from the ID token (and UserInfo if available). This is the correct way to identify the user.
  • @RegisteredOAuth2AuthorizedClient retrieves the access token for the shop registration. If the token has expired and a refresh token exists, Spring automatically refreshes it before returning.

Calling /me after login returns:

{"subject":"customer-42","email":"lan@example.com","name":"Nguyễn Thị Lan"}

Note: Map.of throws NullPointerException if a value is null. The email claim only exists if you request the email scope and the user has an email, so always handle the case where a claim is missing.

5. Building a Resource Server That Validates JWT

5.1. Dependencies and Configuration

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
server:
  port: 8081
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8180/realms/shop
          audiences: order-api

With this configuration, Spring validates each request in the following steps:

  • Retrieves the public key from the issuer’s jwks_uri.
  • Verifies the signature and checks exp/nbf.
  • Verifies iss matches issuer-uri.
  • Verifies aud contains order-api.

Skipping audience validation is a common mistake. Without it, the API will accept tokens issued for other services in the same realm.

5.2. Authorization by Scope

public record Order(String id, String customerId, BigDecimal total) {}
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders.read")
                .anyRequest().authenticated())
            .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()))
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(AbstractHttpConfigurer::disable);
        return http.build();
    }
}
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping
    public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
        return orderService.findByCustomer(jwt.getSubject());
    }
}

By default, Spring converts the scope (or scp) claim into authorities prefixed with SCOPE_. So the scope orders.read becomes SCOPE_orders.read.

Why disable CSRF? This API is stateless and accepts tokens only via the Authorization header, not cookies. Therefore, disabling CSRF makes sense.

The controller extracts sub from the token rather than accepting customerId from the query string. This prevents users from viewing orders of other customers.

Results for different request types:

No token                          -> 401 Unauthorized
Valid token but missing scope     -> 403 Forbidden
Valid token with orders.read      -> 200 OK + user's own orders

Note: Keycloak places roles in realm_access.roles, not in scope. If you want to authorize by role, declare a custom JwtAuthenticationConverter to map that claim to GrantedAuthority.

6. Testing the Resource Server

You don’t need a real Keycloak to test. The spring-security-test library provides a jwt() post-processor to simulate an authenticated token.

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(ResourceServerConfig.class)
class OrderControllerTest {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    OrderService orderService;

    @MockitoBean
    JwtDecoder jwtDecoder; // avoid calling the real issuer during context startup

    @Test
    void returnsOrdersOfCurrentCustomer() {
        given(orderService.findByCustomer("customer-42"))
                .willReturn(List.of(new Order("A-1001", "customer-42", new BigDecimal("250000"))));

        assertThat(mvc.get().uri("/api/orders")
                .with(jwt().jwt(j -> j.subject("customer-42"))
                           .authorities(new SimpleGrantedAuthority("SCOPE_orders.read"))))
                .hasStatusOk()
                .bodyJson().extractingPath("$[0].id").isEqualTo("A-1001");
    }

    @Test
    void rejectsTokenWithoutScope() {
        assertThat(mvc.get().uri("/api/orders").with(jwt()))
                .hasStatus(HttpStatus.FORBIDDEN);
    }

    @Test
    void rejectsRequestWithoutToken() {
        assertThat(mvc.get().uri("/api/orders"))
                .hasStatus(HttpStatus.UNAUTHORIZED);
    }
}

These three tests cover the three branches from section 5.2:

  • jwt() skips decoding. It places a JwtAuthenticationToken directly in SecurityContext, making tests fast and independent.
  • You must explicitly pass authorities(...). The jwt() method defaults to only SCOPE_read, so the second test expects a 403.
  • MockMvcTester (Spring Boot 3.4+) allows writing assertions with AssertJ directly on the response.

7. Common Mistakes and When Not to Use

Using the ID token to call an API. The ID token’s aud is the client, not the API. A properly configured resource server will reject it. Always send the access token.

Using the access token to log in. In pure OAuth 2.0, the access token may be an opaque string with no user information. If a client parses the access token JWT to extract identity, it’s relying on a format that is not guaranteed. Use the ID token or the UserInfo endpoint instead.

Storing tokens in localStorage in an SPA. A single XSS vulnerability is enough to expose the entire token. A safer pattern is BFF (Backend for Frontend). The Spring application in section 4 keeps tokens on the server; the browser only has an HttpOnly session cookie.

Skipping audience validation, or letting access tokens live too long. JWTs cannot be revoked before expiration. Keep access token lifetime short (5–15 minutes) and use refresh tokens to extend the session.

Mismatched issuer-uri. If Keycloak returns iss as http://keycloak:8080/... (the hostname inside Docker) but you configure localhost:8180, all tokens are rejected with invalid_token. The iss value must match exactly.

Note: You don’t always need OAuth 2.0. If you only have a single monolithic application with form-based login and no third parties calling your API, Spring Security’s standard session approach is simpler and safer. For machine-to-machine communication without a user, use the client_credentials grant, not Authorization Code.

8. Conclusion

OAuth 2.0 answers the question “what is this application allowed to do” with an access token. OIDC answers the question “who is this user” with an ID token. The recommended flow is Authorization Code + PKCE, along with state and nonce.

With Spring Boot 3, you’ve learned:

  • spring-boot-starter-oauth2-client with oauth2Login handles the entire OIDC login flow on the client side.
  • spring-boot-starter-oauth2-resource-server validates JWT based on issuer-uri and audiences.
  • spring-security-test lets you test scope-based authorization without a real authorization server.

Next steps you can explore include mapping Keycloak roles with a JwtAuthenticationConverter, or refactoring the client into a BFF for an SPA.

oauth2openid-connectspring-securityjwtweb-security