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.
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
openidscope 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
{
"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
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.comDiscovery 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
@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
| Scope | Claims returned |
|---|---|
openid | sub — required for OIDC at all |
profile | name, given_name, family_name, picture, locale |
email | email, email_verified |
address | address |
phone | phone_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
@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:
// 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?
Do I need to validate the nonce?
How does logout work across multiple applications?
Related tutorials
- OAuth 2.0 Resource ServerValidating tokens correctly: NimbusJwtDecoder configuration, issuer and audience validators, mapping claims to authorities, opaque token introspection and multi-tenant decoding.
- Social Login — Google, GitHub, MicrosoftAdding sign in with Google, GitHub and Microsoft: client registration, mapping provider profiles to your user model, safe account linking, and the onboarding flow afterwards.
- Spring Authorization ServerRunning your own OAuth 2.1 and OIDC provider: registering clients, persisting authorizations, JWK sources and key rotation, custom claims, and the consent page.
- SAML 2.0 AuthenticationEnterprise SSO with SAML: the SP-initiated flow step by step, RelyingPartyRegistration, the assertion checks that matter, metadata exchange and single logout.