skip to content

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?

level: principalimportance: should knowfreq 35%

answer

  1. Delegate to default first (validation intact)
  2. Key local account on (iss, sub), not email
  3. JIT provision + upsert on unique constraint
  4. Branch on registrationId per provider
  5. Deny via OAuth2AuthenticationException

basics

~20 s

Plug 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 s

Register 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
java
@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

for a junior

Know that you can customize login to create a local user and assign roles via a custom user service.

for a middle

Implement a delegating OidcUserService that maps claims to authorities and returns a DefaultOidcUser.

for a senior

Design stable identity keys, JIT provisioning, per-provider branching, and admission control via OAuth2AuthenticationException.

for a principal

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

context