Skip to content
JavaAgentic

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

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.

Advanced5 min readUpdated
On this page

OAuth 2.0 answers "may this application access this resource". It says nothing about who the user is. OpenID Connect adds that missing layer, and the distinction matters because using OAuth alone for login is a well-documented mistake.

Key Takeaways

  • The ID token identifies the user to your application; the access token is for APIs.
  • Validate the ID token's signature, issuer, audience, expiry and nonce.
  • The openid scope is what turns an OAuth flow into an OIDC one.
  • Discovery removes hard-coded endpoint URLs from every environment.
  • Back-channel logout ends sessions the user never visits during logout.

The ID token

decoded ID token payload
{
  "iss": "https://auth.acme.com",
  "sub": "user_8Fj3kQ",
  "aud": "acme-web",
  "exp": 1785500000,
  "iat": 1785499100,
  "auth_time": 1785499090,
  "nonce": "n-0S6_WzA2Mj",
  "email": "alice@example.com",
  "email_verified": true,
  "name": "Alice Chen",
  "picture": "https://cdn.acme.com/avatars/alice.png"
}

Five claims do security work. iss names the issuer, so you can reject tokens from providers you do not trust. aud is your client id — an ID token whose audience is a different application is not for you. nonce matches the value you sent in the authorization request, binding the token to your session. auth_time says when the user actually authenticated, which matters when you want to force reauthentication for a sensitive operation. And sub is the stable user identifier.

Use sub as your key, not email. Email addresses change, and treating one as an identity means a user who changes theirs becomes a different person to your system — or worse, inherits the account of whoever held it before.

The corollary is worth stating plainly, because it is the most common OIDC mistake in the wild: do not send the ID token to your API. Its audience is your client id, not the resource server, so a resource server that accepts it is either skipping the audience check or trusting the wrong audience — and whichever it is, that weakness applies to every token it sees, not only this one. The access token is the credential for API calls; the ID token is for you, and you validate it and discard it.

Configuration

application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          acme:
            client-id: ${OIDC_CLIENT_ID}
            client-secret: ${OIDC_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            # The openid scope is what makes this OIDC rather than plain OAuth.
            scope: 'openid,profile,email'
            redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
        provider:
          acme:
            # Everything else is discovered from
            # /.well-known/openid-configuration — no hard-coded endpoints.
            issuer-uri: https://auth.acme.com

Discovery is worth using rather than listing endpoints individually. It removes four URLs per environment that can drift, and it picks up a provider's endpoint changes automatically.

Mapping to your user model

CustomOidcUserService.java
@Service
public class CustomOidcUserService extends OidcUserService {
 
    private final UserRepository users;
 
    @Override
    public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException {
        OidcUser oidcUser = super.loadUser(request);
 
        String subject = oidcUser.getSubject();
        String email = oidcUser.getEmail();
 
        // Trusting an unverified email lets someone register with an address
        // they do not control and inherit an existing account by linking.
        if (!Boolean.TRUE.equals(oidcUser.getEmailVerified())) {
            throw new OAuth2AuthenticationException(
                    new OAuth2Error("email_not_verified", "Verify your email first", null));
        }
 
        User user = users.findByProviderAndSubject("acme", subject)
                .orElseGet(() -> users.save(User.fromOidc(subject, email, oidcUser.getFullName())));
 
        user.recordLogin(Instant.now());
 
        var authorities = new ArrayList<GrantedAuthority>(oidcUser.getAuthorities());
        user.roles().forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r.name())));
 
        return new DefaultOidcUser(authorities, request.getIdToken(),
                                   oidcUser.getUserInfo(), "sub");
    }
}

The email_verified check is not optional. Without it, account linking by email lets an attacker register at a permissive identity provider using a victim's address and take over the existing account.

Providers also differ on where claims actually arrive. Some put email and name directly into the ID token; others issue a minimal token and expect you to call the UserInfo endpoint for everything else. OidcUserService papers over the difference by fetching UserInfo when a requested scope implies claims the token does not carry, which is why the mapping above can call getEmail() without knowing or caring which of the two happened.

Standard scopes

ScopeClaims returned
openidsub — required for OIDC at all
profilename, given_name, family_name, picture, locale
emailemail, email_verified
addressaddress
phonephone_number, phone_number_verified

Request only what you use. A consent screen listing five scopes gets read; one listing fifteen does not, and unread consent is not meaningful consent. It also reduces what you are responsible for protecting.

Logout

RP-initiated logout ends the session the user is looking at. Back-channel logout ends every session, including in applications they never opened during logout.
LogoutConfig.java
@Bean
LogoutSuccessHandler oidcLogoutSuccessHandler(ClientRegistrationRepository registrations) {
    var handler = new OidcClientInitiatedLogoutSuccessHandler(registrations);
    // Must be registered with the provider as a post-logout redirect URI, or
    // the provider will refuse to redirect back.
    handler.setPostLogoutRedirectUri("{baseUrl}/");
    return handler;
}
 
@Bean
SecurityFilterChain chain(HttpSecurity http, LogoutSuccessHandler handler) throws Exception {
    http.oauth2Login(Customizer.withDefaults())
        .logout(logout -> logout
            .logoutSuccessHandler(handler)
            .invalidateHttpSession(true)
            .deleteCookies("JSESSIONID"))
        // Provider POSTs a logout token here when the session ends elsewhere.
        .oidc(oidc -> oidc.backChannel(Customizer.withDefaults()));
    return http.build();
}

Local logout alone leaves the provider session intact, so clicking "sign in" immediately signs the user straight back in without a prompt. That surprises users and is a genuine problem on shared machines.

Forcing reauthentication

For a sensitive action — changing a password, approving a payment — you may want proof the user authenticated recently rather than an hour ago:

StepUp.java
// max_age asks the provider to reauthenticate if the last authentication
// is older than this. auth_time in the resulting token proves when.
String authorizeUrl = UriComponentsBuilder
        .fromUriString(authorizationEndpoint)
        .queryParam("max_age", 300)
        .queryParam("prompt", "login")
        .build().toUriString();

Check auth_time in the returned token rather than trusting that the provider honoured the request — max_age is a request, and not every provider implements it strictly.

What to take away

Use OIDC rather than raw OAuth when you need identity, and key users on sub rather than email. Validate issuer, audience, expiry and nonce on the ID token, and refuse unverified emails before linking accounts. Configure by issuer URI so endpoints are discovered, and wire both RP-initiated and back-channel logout so sessions actually end.

Frequently Asked Questions

What is the difference between an ID token and an access token?
An ID token tells your application who the user is — it is for you, and you validate and discard it. An access token is a credential you present to an API — it is for the resource server, and you should treat it as opaque. Sending an ID token to an API is a common and incorrect pattern.
Do I need to validate the nonce?
Yes, for the authorization code flow with an ID token. The nonce binds the token to your specific authentication request, preventing token replay from a different session. Spring validates it automatically when using oauth2Login; a hand-rolled implementation must do it explicitly.
How does logout work across multiple applications?
RP-initiated logout redirects the user to the provider end-session endpoint, which ends the session there and can redirect back. Back-channel logout is stronger: the provider POSTs a logout token to each relying party, so sessions end even in applications the user never visits during logout.

Related tutorials