You need to provision local accounts and map provider claims to roles on OAuth2/OIDC login across multiple providers. How do you design this in Spring Security?
answer
- Delegate to default first (validation intact)
- Key local account on (iss, sub), not email
- JIT provision + upsert on unique constraint
- Branch on registrationId per provider
- Deny via OAuth2AuthenticationException
basics
~20 sPlug in a custom OAuth2UserService/OidcUserService that delegates to the default, then enriches the principal: look up or just-in-time create a local account keyed by issuer+subject, and translate provider claims into GrantedAuthorities. Return a custom OAuth2User/OidcUser carrying those authorities.
solid answer
~40 sRegister a custom `OAuth2UserService<OidcUserRequest, OidcUser>` (and a sibling for non-OIDC) via `oauth2Login().userInfoEndpoint().oidcUserService(...)`. Inside, first call the built-in `OidcUserService` so id_token validation and userinfo loading still happen. Then key the local identity on the stable `(issuer, subject)` pair — never email alone, which can change/collide — to look up or just-in-time provision a local `User` row. Derive authorities from a combination of local roles and provider claims (groups/roles), returning a custom principal (e.g. `DefaultOidcUser` with your authorities, or your own class implementing `OidcUser`). Use the `registrationId` from the `OAuth2UserRequest` to branch per provider, since claim shapes differ. Handle failure modes explicitly: unknown/disallowed users → throw `OAuth2AuthenticationException` to deny login; deactivated accounts → block. Keep the mapping deterministic and idempotent, and store the provider linkage so the same human maps to one local account across logins.
code
java · 23 lines@Bean
OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService(UserAccountService accounts) {
OidcUserService delegate = new OidcUserService();
return request -> {
OidcUser oidc = delegate.loadUser(request); // validates id_token + loads userinfo
String issuer = oidc.getIssuer().toString();
String subject = oidc.getSubject();
String provider = request.getClientRegistration().getRegistrationId();
// JIT provision / load local account keyed on stable (issuer, subject)
LocalUser local = accounts.findOrProvision(issuer, subject, oidc.getEmail(), provider);
if (!local.isEnabled()) {
throw new OAuth2AuthenticationException(new OAuth2Error("account_disabled"));
}
Set<GrantedAuthority> authorities = new HashSet<>();
local.getRoles().forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
// provider claims are input, local roles are source of truth
return new DefaultOidcUser(authorities, oidc.getIdToken(), oidc.getUserInfo(), "sub");
};
}
// http.oauth2Login(o -> o.userInfoEndpoint(u -> u.oidcUserService(oidcUserService(accounts))));go deeper
Know that you can customize login to create a local user and assign roles via a custom user service.
Implement a delegating OidcUserService that maps claims to authorities and returns a DefaultOidcUser.
Design stable identity keys, JIT provisioning, per-provider branching, and admission control via OAuth2AuthenticationException.
Own the whole identity strategy: multi-provider linkage, source-of-truth roles vs provider input, race-safe provisioning, session-role-change handling, and hot-path resilience.
**Problem framing.** Real apps rarely want the raw provider principal. They want: (a) a *local* account (for app-specific data, roles, audit) and (b) app roles derived from provider claims — across *several* providers whose claim formats differ. Spring gives you the `OAuth2UserService` seam for exactly this. **The seam.** After token exchange Spring calls an `OAuth2UserService` to produce the principal. Register: - `oauth2Login().userInfoEndpoint().oidcUserService(customOidc)` for OIDC providers, and - `.userService(customOAuth2)` for plain OAuth2 (e.g. GitHub). Always **delegate to the built-in** (`OidcUserService` / `DefaultOAuth2UserService`) first so signature/id_token validation and userinfo retrieval aren't skipped. Then wrap/replace the returned user. **Stable identity key.** The join key between provider identity and your local account must be stable and unique *within an issuer*: use `(iss, sub)` — the OIDC issuer plus subject. Pitfalls if you use email: emails get reassigned, users change them, and the *same* email at two providers is two different humans. Store `provider_registration_id` / `issuer` + `subject` on the local account (or a linking table) so one human = one local account, and so you can support account linking. **Just-in-time (JIT) provisioning.** On first login, if no local account matches, create one (subject to a policy — open self-service vs. invite-only). On subsequent logins, load and refresh mutable attributes (name, email, avatar). Make this idempotent and transactional; concurrent first-logins can race, so upsert on the unique `(issuer, subject)` constraint. **Authority mapping.** Authorities can come from: - Local roles stored in your DB (authoritative for app permissions). - Provider claims: `groups`, `roles`, or scopes — normalized to `ROLE_*` / custom authorities. Branch on `userRequest.getClientRegistration().getRegistrationId()` because Okta's `groups`, Azure's `roles`, and GitHub's org/team data all differ. Prefer local roles as the source of truth for authorization decisions; treat provider groups as *input*, not gospel, unless the provider is your corporate IdP. **Denying logins.** To reject a user (not allowlisted, deactivated, email unverified, wrong tenant), throw `OAuth2AuthenticationException` from `loadUser` — Spring turns it into an authentication failure rather than a logged-in session. This is the correct place for admission control. **Return type.** Keep it an `OidcUser` (e.g. `new DefaultOidcUser(authorities, idToken, userInfo, userNameAttrName)`) so downstream code and `@AuthenticationPrincipal OidcUser` still work and the id_token stays reachable. For plain OAuth2 return a `DefaultOAuth2User`. You can also implement `OidcUser` on a custom class that also carries your local `userId`. **Multi-tenant / many providers.** Combine with a custom `ClientRegistrationRepository` (dynamic registrations) and a `GrantedAuthoritiesMapper` if you only need authority mapping without provisioning. For heavy needs, the custom user service is the right tool. **Session & consistency.** After login the authorities are fixed for the session; if roles change mid-session, you need a re-auth or a session-refresh strategy. Cache provider JWK/userinfo appropriately; don't hammer the userinfo endpoint. **Testing.** Use `spring-security-test` with `oidcLogin()`/`oauth2Login()` post-processors and stub the user service, or test the mapping logic in isolation. Verify: unknown user denied, JIT creates exactly one account, claim→role mapping per provider, and that delegation preserves id_token validation. **Common mistakes.** - Skipping delegation → id_token never validated. - Keying on email → account takeover / collisions. - Trusting provider groups directly for privileged authorities from a consumer IdP. - Doing blocking DB/network work without timeouts inside the user service (it's on the login hot path). - Returning a bare `OAuth2User` for an OIDC login, losing the id_token.
- Why key the local account on (issuer, subject) rather than email?Because sub is a stable, provider-guaranteed unique identifier scoped to the issuer, while email can change, be reassigned, be unverified, or collide across providers. Keying on email risks account takeover and merges distinct users; (issuer, subject) guarantees a 1:1, stable mapping and cleanly supports multiple providers.
- How do you reject a user who shouldn't be allowed to log in?Throw an OAuth2AuthenticationException (with an OAuth2Error) from loadUser in your custom OAuth2UserService/OidcUserService. Spring converts it into an authentication failure, so no session is created. This is the right admission-control point for allowlists, disabled accounts, unverified email, or wrong tenant.
- What happens to the id_token if you return a plain OAuth2User for an OIDC login?You lose easy access to the id_token and OIDC claims — code relying on @AuthenticationPrincipal OidcUser or getIdToken() breaks. Always return an OidcUser (e.g. DefaultOidcUser) for OIDC logins so the validated id_token remains reachable.
saying these in an interview costs you the question
- Keying local accounts on email instead of (issuer, subject)
- Overriding the user service without delegating, so id_token validation is skipped
- Blindly trusting provider group claims for privileged authorities
- Returning OAuth2User for OIDC logins and losing the id_token
- Doing unbounded blocking I/O in the user service on the login hot path