Spring Boot
OAuth2 Login
Add Google, GitHub or Keycloak login to Spring Boot with spring-boot-starter-oauth2-client: setup, OAuth2 vs OIDC user services, account mapping, logout, SPA setup and testing.
How the Authorization Code Flow Works
OAuth2 Login is the Authorization Code flow with Spring Security doing the client's work. The user clicks a provider link, your application redirects the browser to the provider with a random state value, the user authenticates and consents there, and the provider redirects back to your callback URL with a one-time code. Your server then calls the provider's token endpoint directly, using its client secret, to exchange that code for tokens. It fetches the user's profile, and finally creates an authenticated session. The password never touches your application.
Three details explain most of the problems people hit later. The authorization request, including the state value, is stored in the HTTP session between the redirect and the callback, so the flow needs working sessions. The callback URL must match exactly what you registered with the provider, down to scheme, host, port and path. And whether you receive an OAuth2User or an OidcUser depends on whether the provider speaks OpenID Connect, which is triggered by the openid scope. That choice decides which of your custom classes is called, as the next sections explain.
Shell
# Request flow
#
# Browser Your app (Spring Security) Provider
# | GET /oauth2/authorization/google | |
# |------------------------------------>| |
# | 302 -> provider /authorize? | |
# | client_id, scope, state, | |
# | redirect_uri | |
# |<------------------------------------| |
# | user signs in and consents ------------------------------------>|
# | 302 -> /login/oauth2/code/google?code=...&state=... |
# |------------------------------------>| POST /token (code+secret) |
# | |--------------------------->|
# | | access_token, id_token |
# | |<---------------------------|
# | | GET /userinfo (non-OIDC) |
# | 302 -> / with session cookie | |
# |<------------------------------------| |
# Endpoints Spring Security registers for a registration called "google":
# GET /oauth2/authorization/google starts the flow
# GET /login/oauth2/code/google callback, receives the code
# GET /login generated page listing providers
# POST /logout ends the session (needs a CSRF token)Dependencies
The starter brings Spring Security, the OAuth2 client support and the JOSE library used to validate ID tokens. Add spring-boot-starter-web (or webflux) for the application itself, and Thymeleaf with its Spring Security extras if you render pages on the server and want sec:authorize in templates.
XML
<!-- pom.xml -->
<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>
<!-- Optional: server-rendered pages with sec:authorize -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
<!-- Optional: testing the secured endpoints -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>Quick Start: Google Login in Three Steps
If you only want working social login, you need far less than the rest of this guide shows. With the starter on the classpath and a registration in application.yml, Spring Boot auto-configures OAuth2 Login on its own: every URL requires authentication, and Spring generates a login page at /login that lists the configured providers. You do not have to write a SecurityFilterChain bean until you want custom rules.
Create an OAuth2 client in the Google Cloud Console, add http://localhost:8080/login/oauth2/code/google as an authorized redirect URI, and put the client id and secret in environment variables. Open /hello in the browser, sign in at Google, and you are sent back to /hello with a session. The sections below build on this: custom security rules, other providers, mapping users to your own table, logout and testing.
Java
// 1. application.yml, the only configuration needed
//
// spring:
// security:
// oauth2:
// client:
// registration:
// google:
// client-id: ${GOOGLE_CLIENT_ID}
// client-secret: ${GOOGLE_CLIENT_SECRET}
//
// 2. Register this redirect URI in the Google Cloud Console:
// http://localhost:8080/login/oauth2/code/google
//
// 3. Add any endpoint. No SecurityFilterChain bean is needed yet:
// Boot's default secures every URL and redirects to the login page.
@RestController
class HelloController {
@GetMapping("/hello")
String hello(@AuthenticationPrincipal OidcUser user) {
return "Hello, " + user.getFullName() + " (" + user.getEmail() + ")";
}
}Registering Providers
Each provider is a registration in application.yml, keyed by a registration id you choose. For Google, GitHub and Facebook, Spring Security ships the endpoint URLs, so you only supply the client id and secret, and the registration id must match the provider name (google, github, facebook) for those defaults to apply. If you want a different id, add provider: github under the registration to say which defaults to use. Okta is also known to the library, but it needs your own domain, so configure it like any OIDC provider with an issuer URI.
Create the client credentials in the provider's developer console and register the redirect URI there. With Spring's default of {baseUrl}/login/oauth2/code/{registrationId}, that is http://localhost:8080/login/oauth2/code/google for local development and your https address in production. Keep secrets out of the file; read them from environment variables or a secrets manager.
Scopes decide what you receive. With openid (Google's default) the flow is OpenID Connect and the user's identity arrives in a signed ID token. GitHub does not implement OIDC, so Spring calls its user endpoint after the token exchange, and the user:email scope is needed to see addresses that are not public.
yaml
# application.yml
spring:
security:
oauth2:
client:
registration:
google: # built-in defaults, id must be "google"
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: openid, profile, email
github: # built-in defaults, id must be "github"
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope: read:user, user:email
facebook:
client-id: ${FACEBOOK_CLIENT_ID}
client-secret: ${FACEBOOK_CLIENT_SECRET}
scope: email, public_profile
# Custom id, borrowing GitHub's built-in endpoints
# (for example a second GitHub OAuth app with its own credentials):
github-secondary:
provider: github
client-id: ${GH_ALT_CLIENT_ID}
client-secret: ${GH_ALT_CLIENT_SECRET}
# Redirect URI registered at each provider (default template):
# {baseUrl}/login/oauth2/code/{registrationId}
# e.g. https://app.example.com/login/oauth2/code/google
# Built in: google, github, facebook (and okta, but it still needs your issuer URI).
# Anything else (Keycloak, Azure AD, Auth0...) needs a provider block, shown below.Security Configuration for a Server-Rendered App
For a traditional application that renders pages on the server, the configuration is short. Declare which URLs are public, enable oauth2Login, and optionally supply your own login page. If you set loginPage you must also make that path public and provide a controller and template for it. Forgetting the permitAll() on the login page is the usual cause of a redirect loop.
If you do not set loginPage, Spring generates a basic page at /login that lists every registered provider, which is perfectly adequate to start with. Logout is a POST request protected by CSRF, so a logout link must be a form with the token, which th:action adds automatically in Thymeleaf.
Be aware that defaultSuccessUrl with alwaysUse set to true discards the page the user originally tried to open. Leave it false, the default, if you want users to land where they were headed before being asked to log in.
Java
@Configuration
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/error", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.loginPage("/login") // optional custom page; must be permitAll
.defaultSuccessUrl("/dashboard") // only used if no saved request exists
.failureUrl("/login?error"))
.logout(logout -> logout
.logoutSuccessUrl("/")); // POST /logout, with CSRF token
return http.build();
}
}
// Controller and template for the custom login page
@Controller
class LoginController {
@GetMapping("/login")
String login() {
return "login";
}
}
// templates/login.html
// <a th:href="@{/oauth2/authorization/google}">Sign in with Google</a>
// <a th:href="@{/oauth2/authorization/github}">Sign in with GitHub</a>
//
// templates/fragments: logout must be a POST with a CSRF token
// <form th:action="@{/logout}" method="post"><button>Sign out</button></form>OAuth2User or OidcUser: Which Service Runs
This is the most common misunderstanding. Spring Security has two separate hooks for loading the user, and which one fires depends on the provider. Providers that return an ID token because the openid scope was requested, such as Google and Keycloak, are processed by an OidcUserService and produce an OidcUser. Plain OAuth2 providers, such as GitHub and Facebook, are processed by an OAuth2UserService and produce an OAuth2User. An OidcUser is also an OAuth2User, but the reverse is not true.
The consequence is that a custom OAuth2UserService registered with userInfoEndpoint().userService(...) is never called for Google. If you want to map every login to a local account, you have to register both: userService for OAuth2 providers and oidcUserService for OIDC ones. The same applies to controllers. A method parameter typed as your own OAuth2User class receives nothing for an OIDC login unless the principal you return is an instance of that type too.
The name of the principal, the value returned by getName(), comes from the user-name-attribute of the registration: sub for Google, id for GitHub. Spring uses it as the identity of the session, so it must be a stable, unique identifier, never an email address or display name that can change.
Java
// Which hook runs?
//
// Provider / scope Hook Principal type
// ------------------------------------- ---------------------------- --------------
// Google, openid scope oidcUserService(...) OidcUser
// Keycloak, Okta, Azure AD (openid) oidcUserService(...) OidcUser
// GitHub, Facebook (no OIDC) userService(...) OAuth2User
//
// Register BOTH to cover all providers:
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo
.userService(appOAuth2UserService) // GitHub, Facebook
.oidcUserService(appOidcUserService))) // Google, Keycloak, ...
// user-name-attribute (what getName() returns) per provider:
// google sub
// github id
// facebook id
// Override for a custom provider:
// spring.security.oauth2.client.provider.<id>.user-name-attribute: subMapping Provider Users to Local Accounts
Most applications keep their own user table for roles, preferences and relationships, and treat the provider only as proof of identity. The key under which you find the local record is the pair (provider, provider user id). The email address is a convenience, not an identity: users change it, and some providers let people add unverified ones.
Linking a new login to an existing account because the email matches is a classic account-takeover vector. If an attacker can create a provider account that claims the victim's address without verifying it, they would be signed in as the victim. Only link on a matching email when the provider asserts that the address is verified (the email_verified claim for OIDC providers, or the verified flag on GitHub's email list), and consider requiring the user to sign in with their existing method first and then connect the provider from their profile.
GitHub deserves a special mention. Its user object contains an email attribute only when the user has made an address public. Otherwise it is null, even with the user:email scope. To get the real address you call the /user/emails endpoint with the access token and pick the primary, verified entry.
Keep the lookup-or-create logic in one service that both the OAuth2 and OIDC hooks call, so the rules cannot drift apart.
Java
@Service
public class LocalUserService {
private final UserRepository users;
public LocalUserService(UserRepository users) {
this.users = users;
}
@Transactional
public User findOrCreate(String provider, String providerId,
String email, boolean emailVerified, String name) {
// 1. The stable identity: provider + provider's user id
Optional<User> byProvider = users.findByProviderAndProviderId(provider, providerId);
if (byProvider.isPresent()) {
User user = byProvider.get();
user.setLastLoginAt(Instant.now());
return user;
}
// 2. Link to an existing account ONLY if the provider verified the email
if (email != null && emailVerified) {
Optional<User> byEmail = users.findByEmailIgnoreCase(email);
if (byEmail.isPresent()) {
User existing = byEmail.get();
existing.linkProvider(provider, providerId);
return existing;
}
} else if (email != null && users.existsByEmailIgnoreCase(email)) {
// Unverified email that collides with an existing account: refuse.
throw new OAuth2AuthenticationException(
new OAuth2Error("email_unverified",
"The email address from this provider is not verified.", null));
}
// 3. First login: create the local user
User created = new User();
created.linkProvider(provider, providerId);
created.setEmail(email);
created.setName(name);
created.setRoles(Set.of("USER"));
created.setLastLoginAt(Instant.now());
return users.save(created);
}
}
// GitHub: the public profile often has no email. Ask /user/emails for it.
@Component
public class GitHubEmailClient {
private final RestClient rest = RestClient.create("https://api.github.com");
public record GitHubEmail(String email, boolean primary, boolean verified) { }
public Optional<GitHubEmail> primaryVerified(String accessToken) {
List<GitHubEmail> emails = rest.get()
.uri("/user/emails")
.headers(h -> h.setBearerAuth(accessToken))
.retrieve()
.body(new ParameterizedTypeReference<List<GitHubEmail>>() { });
return emails == null ? Optional.empty()
: emails.stream().filter(e -> e.primary() && e.verified()).findFirst();
}
}Custom User Services for OAuth2 and OIDC
Both services follow the same pattern: call the superclass to let Spring do the standard work (token validation, user info), read the attributes you need, find or create the local user, and return a principal whose authorities come from your own roles. Returning provider-supplied authorities such as SCOPE_email is harmless but is not what you want to base authorization on.
For the OIDC path, return an object that is still an OidcUser, because Spring Security and other components expect it, for example the logout handler needs the ID token. Extending DefaultOidcUser and adding the local user is the simplest way. A small shared interface lets controllers accept either kind of principal without caring which provider was used.
Never put secrets or the access token into the principal; it is serialised into the session.
Java
// Shared contract so controllers don't care how the user logged in
public interface LocalUserPrincipal {
User getLocalUser();
}
// OAuth2 (non-OIDC) principal
public class LocalOAuth2User implements OAuth2User, LocalUserPrincipal {
private final OAuth2User delegate;
private final User localUser;
public LocalOAuth2User(OAuth2User delegate, User localUser) {
this.delegate = delegate;
this.localUser = localUser;
}
@Override public Map<String, Object> getAttributes() { return delegate.getAttributes(); }
@Override public Collection<? extends GrantedAuthority> getAuthorities() {
return localUser.getRoles().stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
}
@Override public String getName() { return String.valueOf(localUser.getId()); }
@Override public User getLocalUser() { return localUser; }
}
// OIDC principal: still an OidcUser (keeps the ID token for logout etc.)
public class LocalOidcUser extends DefaultOidcUser implements LocalUserPrincipal {
private final User localUser;
public LocalOidcUser(OidcUser oidc, User localUser) {
super(localUser.getRoles().stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList(),
oidc.getIdToken(), oidc.getUserInfo());
this.localUser = localUser;
}
@Override public User getLocalUser() { return localUser; }
}
@Service
public class AppOAuth2UserService extends DefaultOAuth2UserService {
private final LocalUserService localUsers;
private final GitHubEmailClient github;
public AppOAuth2UserService(LocalUserService localUsers, GitHubEmailClient github) {
this.localUsers = localUsers;
this.github = github;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
OAuth2User raw = super.loadUser(request);
String provider = request.getClientRegistration().getRegistrationId();
String email = raw.getAttribute("email");
boolean verified = false;
String name = raw.getAttribute("name");
if ("github".equals(provider)) {
Optional<GitHubEmailClient.GitHubEmail> primary =
github.primaryVerified(request.getAccessToken().getTokenValue());
email = primary.map(GitHubEmailClient.GitHubEmail::email).orElse(null);
verified = primary.isPresent();
if (name == null) name = raw.getAttribute("login");
}
User local = localUsers.findOrCreate(provider, raw.getName(), email, verified, name);
return new LocalOAuth2User(raw, local);
}
}
@Service
public class AppOidcUserService extends OidcUserService {
private final LocalUserService localUsers;
public AppOidcUserService(LocalUserService localUsers) {
this.localUsers = localUsers;
}
@Override
public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException {
OidcUser oidc = super.loadUser(request);
String provider = request.getClientRegistration().getRegistrationId();
User local = localUsers.findOrCreate(
provider, oidc.getSubject(), oidc.getEmail(),
Boolean.TRUE.equals(oidc.getEmailVerified()), oidc.getFullName());
return new LocalOidcUser(oidc, local);
}
}
// Wire both into the filter chain
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(u -> u
.userService(appOAuth2UserService)
.oidcUserService(appOidcUserService)))OpenID Connect Providers: Keycloak, Okta and Azure AD
Any OpenID Connect provider is registered the same way: a registration with your client id and secret, plus a provider entry containing the issuer URI. At startup Spring fetches the issuer's /.well-known/openid-configuration document and learns every endpoint and signing key from it, so you do not list URLs by hand. The application therefore needs to reach the issuer when it starts. If the provider is down, or the URL is wrong, startup fails.
The issuer URI has to match the iss claim in the tokens exactly. This is the most frequent Keycloak problem in containers: the application reaches Keycloak at one hostname (for example keycloak:8080 inside Docker), but Keycloak puts its public hostname in the tokens. Configure Keycloak's hostname so that both agree, or the ID token validation fails.
Behind a reverse proxy or load balancer that terminates TLS, set server.forward-headers-strategy so Spring builds redirect URIs with https and the public host; otherwise the provider will see an http redirect URI and reject it.
yaml
# application.yml
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: myapp
client-secret: ${KEYCLOAK_CLIENT_SECRET}
scope: openid, profile, email
# authorization-grant-type defaults to authorization_code
# redirect-uri defaults to {baseUrl}/login/oauth2/code/{registrationId}
okta:
client-id: ${OKTA_CLIENT_ID}
client-secret: ${OKTA_CLIENT_SECRET}
scope: openid, profile, email
azure:
client-id: ${AZURE_CLIENT_ID}
client-secret: ${AZURE_CLIENT_SECRET}
scope: openid, profile, email
provider:
keycloak:
issuer-uri: https://keycloak.example.com/realms/myrealm
user-name-attribute: sub
okta:
issuer-uri: https://dev-12345.okta.com/oauth2/default
azure:
issuer-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0
# Behind a reverse proxy that terminates TLS:
server:
forward-headers-strategy: framework # honour X-Forwarded-Proto / -Host
# Spring discovers endpoints from, for example:
# https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration
# The issuer here must equal the "iss" claim in the tokens exactly.Reading Roles from Keycloak Tokens
Keycloak puts realm roles in the realm_access.roles claim and client roles under resource_access.<client>.roles. By default these claims are included in the access token, not the ID token. If you read them from the ID token, as many examples do, you will find nothing until you enable "Add to ID token" on the corresponding mapper in the client scope settings. Alternatively decode the access token, or read them from the userinfo response, which is also configurable.
Parse the claims defensively. They are untyped JSON, so cast carefully and tolerate a missing claim. Prefix roles with ROLE_ if you use hasRole() in your rules, or leave them as they are and use hasAuthority().
You can combine this with the local-user mapping from the previous sections: either derive authorities purely from Keycloak, so roles are managed centrally, or from your own table. Mixing the two sources invites surprises; pick one as the source of truth.
Java
@Service
public class KeycloakOidcUserService extends OidcUserService {
@Override
public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException {
OidcUser oidcUser = super.loadUser(request);
Set<GrantedAuthority> authorities = new HashSet<>(oidcUser.getAuthorities());
authorities.addAll(extractRealmRoles(oidcUser.getClaims()));
return new DefaultOidcUser(
authorities, oidcUser.getIdToken(), oidcUser.getUserInfo(), "sub");
}
private List<GrantedAuthority> extractRealmRoles(Map<String, Object> claims) {
Object realmAccess = claims.get("realm_access");
if (!(realmAccess instanceof Map<?, ?> map)) {
return List.of();
}
Object roles = map.get("roles");
if (!(roles instanceof Collection<?> collection)) {
return List.of();
}
return collection.stream()
.filter(String.class::isInstance)
.map(String.class::cast)
.<GrantedAuthority>map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.toList();
}
}
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(u -> u.oidcUserService(keycloakOidcUserService)))
// If the claim is missing:
// Keycloak admin console -> Client scopes -> roles -> Mappers -> realm roles
// enable "Add to ID token" (and/or "Add to userinfo").Logging Out, Including at the Provider
The default logout ends only your application's session. The user is still signed in at the provider, so clicking "Sign in with Google" again signs them straight back in without a prompt. That is correct for many public applications, but for corporate single sign-on you usually want the provider session to end as well.
OpenID Connect defines RP-initiated logout for this. Spring Security implements it in OidcClientInitiatedLogoutSuccessHandler, which redirects to the provider's end_session_endpoint with the ID token as a hint. It works for providers that publish that endpoint in their discovery document, such as Keycloak, Okta and Azure AD. Google does not offer one, so for Google a local logout is all you can do. Register the post-logout redirect URI in the provider's client settings, or the provider will refuse to redirect back.
Java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
ClientRegistrationRepository clientRegistrations) throws Exception {
OidcClientInitiatedLogoutSuccessHandler logoutHandler =
new OidcClientInitiatedLogoutSuccessHandler(clientRegistrations);
logoutHandler.setPostLogoutRedirectUri("{baseUrl}/"); // register this at the provider
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/error").permitAll()
.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults())
.logout(logout -> logout
.logoutSuccessHandler(logoutHandler));
return http.build();
}
// Notes
// - /logout is a POST and needs the CSRF token (use a form with th:action).
// - Works when the provider exposes end_session_endpoint in its OIDC metadata.
// - Needs the principal to be an OidcUser, which is why custom OIDC services
// must still return one (see LocalOidcUser above).
// - Google has no end_session_endpoint: a local logout is all that is possible.Single-Page Apps: Use the Session, Not a JSON Callback
A tempting design for an SPA is to have the success handler write a token as JSON. It does not work, because the callback is a full-page browser navigation that the provider triggered. The browser would simply display your JSON as a page, and your JavaScript never sees it. A related mistake is switching the session policy to STATELESS. OAuth2 Login stores the authorization request in the HTTP session between the redirect and the callback, so with no session the callback fails with authorization_request_not_found.
The simplest sound design is to let the Spring Boot backend own the login and hold the tokens, and give the browser only an HttpOnly session cookie. The success handler redirects back to the SPA, and the SPA asks the backend who the user is through an endpoint such as /api/me. This is the backend-for-frontend pattern. The access and refresh tokens never reach browser JavaScript, so they cannot be stolen by an XSS bug. Calls from the SPA to the API carry the session cookie, which means you need CORS with credentials enabled for a separate frontend origin, and CSRF protection for state-changing requests, typically with the cookie-based token repository.
One CSRF detail catches many people on Spring Security 6. The default request handler masks the token and loads it lazily, so a SPA that copies the XSRF-TOKEN cookie into a header gets 403 on its first POST. Use a plain CsrfTokenRequestAttributeHandler and switch off the lazy loading, as in the configuration below.
If you genuinely need to issue your own JWTs after social login, do it deliberately: set the token as an HttpOnly cookie on a redirect to the SPA rather than putting it in a URL or JSON body, and build the response with a JSON library, never by concatenating strings, which allows injection through values like an email address or an exception message.
Java
@Bean
public SecurityFilterChain spaFilterChain(HttpSecurity http) throws Exception {
String frontend = "https://app.example.com";
// Spring Security 6: let the SPA read the raw XSRF-TOKEN cookie and send it back as a header
CsrfTokenRequestAttributeHandler csrfHandler = new CsrfTokenRequestAttributeHandler();
csrfHandler.setCsrfRequestAttributeName(null); // load the token eagerly so the cookie is always set
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/oauth2/**", "/login/**", "/error").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll())
.oauth2Login(oauth2 -> oauth2
// After login, send the browser back to the SPA. The session cookie is the credential.
.successHandler(new SimpleUrlAuthenticationSuccessHandler(frontend + "/"))
.failureHandler(new SimpleUrlAuthenticationFailureHandler(frontend + "/login?error")))
// API calls get 401 JSON-friendly responses, not a redirect to the login page
.exceptionHandling(ex -> ex
.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
request -> request.getRequestURI().startsWith("/api/")))
// Sessions are REQUIRED here; do not set STATELESS.
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(csrfHandler))
.cors(Customizer.withDefaults()); // allowCredentials(true) and an exact origin: see CORS guide
return http.build();
}
@RestController
class MeController {
// The SPA calls this with credentials: "include" to learn who is logged in.
@GetMapping("/api/me")
Map<String, Object> me(@AuthenticationPrincipal OAuth2User principal) {
return Map.of(
"name", String.valueOf(principal.getAttribute("name")),
"email", String.valueOf(principal.getAttribute("email")));
}
}
// Build JSON with a serializer, never with string concatenation:
// objectMapper.writeValue(response.getWriter(), Map.of("error", "login_failed"));
// Stateless alternative (advanced): replace the session-based authorization request storage
// with a cookie-based AuthorizationRequestRepository, then issue your own cookie after login.Accessing the Logged-In User in Controllers
Inject the principal with @AuthenticationPrincipal. Use OidcUser for OIDC providers to get typed accessors such as getEmail() and getSubject(), OAuth2User for anything, or your own interface if you return custom principals as shown earlier. Authentication is the most general parameter and works for form login too.
Avoid returning raw tokens or the full claims map from an endpoint. ID tokens and access tokens are bearer credentials and attributes may contain more personal data than you meant to expose. Return a small response object with only the fields the page needs.
In Thymeleaf with the Spring Security extras, sec:authentication and the #authentication object expose the same principal. The attribute map is available on OAuth2User principals.
Java
@RestController
@RequestMapping("/api/me")
public class ProfileController {
// Works for GitHub and Google if your services return LocalUserPrincipal
@GetMapping
public UserProfileResponse profile(@AuthenticationPrincipal LocalUserPrincipal principal) {
return UserProfileResponse.from(principal.getLocalUser());
}
// OIDC providers: typed claims
@GetMapping("/oidc")
public Map<String, Object> oidc(@AuthenticationPrincipal OidcUser user) {
return Map.of(
"subject", user.getSubject(),
"email", String.valueOf(user.getEmail()),
"emailVerified", Boolean.TRUE.equals(user.getEmailVerified()),
"name", String.valueOf(user.getFullName()));
// do NOT return user.getIdToken().getTokenValue() or the full claims map
}
// Any authentication type
@GetMapping("/name")
public String name(Authentication authentication) {
return authentication.getName();
}
}
// Thymeleaf
// <div sec:authorize="isAuthenticated()">
// <p>Welcome, <span th:text="${#authentication.principal.attributes['name']}">User</span></p>
// <img th:if="${#authentication.principal.attributes['picture'] != null}"
// th:src="${#authentication.principal.attributes['picture']}" alt="" />
// </div>
// <div sec:authorize="hasRole('ADMIN')">Admin tools</div>Calling the Provider's API with the Access Token
The login produces an access token that your server can use to call the provider's APIs on the user's behalf, for example listing GitHub repositories or reading Google Calendar events. Spring keeps the token in an OAuth2AuthorizedClient, stored per user session. Obtain it in a controller with @RegisteredOAuth2AuthorizedClient and the registration id.
Request the scopes you need up front, since the access token only carries what the user consented to. Spring refreshes an expired access token automatically when a refresh token is available. Not every provider issues one: Google, for instance, returns a refresh token only when you ask for offline access and consent, and GitHub's classic OAuth app tokens do not expire. Keep tokens on the server, as with the BFF design above, and never copy them into responses, logs, or the principal.
Java
@RestController
public class RepoController {
private final RestClient rest = RestClient.create("https://api.github.com");
@GetMapping("/api/github/repos")
public String repos(
@RegisteredOAuth2AuthorizedClient("github") OAuth2AuthorizedClient client) {
String token = client.getAccessToken().getTokenValue();
return rest.get()
.uri("/user/repos?per_page=20")
.headers(h -> h.setBearerAuth(token))
.retrieve()
.body(String.class);
}
}
// Scope must have been requested at login:
// spring.security.oauth2.client.registration.github.scope: read:user, user:email, repo
//
// Inspecting the token metadata
// client.getAccessToken().getScopes()
// client.getAccessToken().getExpiresAt()
// client.getRefreshToken() // may be null
//
// Spring refreshes an expired token automatically when a refresh token exists.Testing OAuth2 Login
You cannot run a real provider in a unit test, and you do not need to. spring-security-test provides MockMvc request post-processors, oauth2Login() and oidcLogin(), that install an authenticated principal as if the flow had completed. Use them to test authorization rules and controllers that read the principal. Add attributes and authorities to model the cases you care about, such as a missing email or an admin role.
These helpers test your code that runs after login. They do not exercise the redirect, token exchange or your OAuth2UserService. Test the services as ordinary classes: build an OAuth2UserRequest or call the LocalUserService directly with representative provider data, including the unverified-email and GitHub-without-email cases, and assert on the created or linked user. For the full flow, use a Testcontainers Keycloak instance in a few end-to-end tests rather than many.
Java
@WebMvcTest(ProfileController.class)
class ProfileControllerTest {
@Autowired private MockMvc mockMvc;
@Test
void unauthenticated_isRedirectedToLogin() throws Exception {
mockMvc.perform(get("/api/me/name"))
.andExpect(status().is3xxRedirection());
}
@Test
void oauth2Login_exposesName() throws Exception {
mockMvc.perform(get("/api/me/name")
.with(oauth2Login().attributes(a -> {
a.put("name", "Alice");
a.put("email", "alice@example.com");
})))
.andExpect(status().isOk());
}
@Test
void oidcLogin_withAdminAuthority_canAccessAdminArea() throws Exception {
mockMvc.perform(get("/admin")
.with(oidcLogin().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isOk());
}
@Test
void oidcLogin_withoutAdminAuthority_isForbidden() throws Exception {
mockMvc.perform(get("/admin").with(oidcLogin()))
.andExpect(status().isForbidden());
}
}
// import static org.springframework.security.test.web.servlet.request
// .SecurityMockMvcRequestPostProcessors.oauth2Login;
// import static ...SecurityMockMvcRequestPostProcessors.oidcLogin;
// Unit test of the account-linking rules
@Test
void unverifiedEmail_matchingExistingAccount_isRejected() {
when(users.findByProviderAndProviderId("github", "42")).thenReturn(Optional.empty());
when(users.existsByEmailIgnoreCase("victim@example.com")).thenReturn(true);
assertThatThrownBy(() ->
service.findOrCreate("github", "42", "victim@example.com", false, "Mallory"))
.isInstanceOf(OAuth2AuthenticationException.class);
}Troubleshooting and Security Checklist
Most failures happen at the boundary between your application and the provider, and the error often arrives as a query parameter on /login?error or as an OAuth2AuthenticationException in the logs. Turn on logging for org.springframework.security.oauth2 and the cause is usually obvious from the message.
On the security side, treat the provider as the source of identity only. Link accounts on verified emails or an explicit user action, authorize with your own roles, keep tokens on the server, always use HTTPS in production, and register exact redirect URIs without wildcards. Spring Security uses PKCE automatically for public clients; for confidential clients it is optional and can be switched on with OAuth2AuthorizationRequestCustomizers.withPkce() as a defence in depth.
Shell
# Symptom -> cause -> fix
# redirect_uri_mismatch (error page at the provider)
# The callback URL differs from what is registered.
# -> register exactly {baseUrl}/login/oauth2/code/{registrationId};
# behind a proxy set server.forward-headers-strategy=framework so it builds https.
# [authorization_request_not_found] on the callback
# The session holding the authorization request is gone.
# -> do not use SessionCreationPolicy.STATELESS; check sticky sessions or a shared
# session store with several instances; check cookie SameSite and domain settings.
# invalid_client / 401 from the token endpoint
# Wrong client id or secret, or the secret is not being resolved.
# -> check the environment variables; check the client's authentication method.
# [invalid_id_token] ... iss or clock skew
# Issuer mismatch (internal vs public hostname) or a skewed server clock.
# -> make issuer-uri equal the "iss" claim; sync the clock with NTP.
# Custom user service never runs for Google or Keycloak
# They are OIDC, handled by oidcUserService, not userService.
# -> register both services.
# @AuthenticationPrincipal MyUser is null
# The principal is not an instance of that type for this provider.
# -> return a principal that implements the type, or inject an interface.
# GitHub email is null
# The user's address is private.
# -> request user:email and call /user/emails with the access token.
# Redirect loop to /login
# The custom login page is not permitAll().
# 403 on logout
# /logout is POST and needs the CSRF token. -> use a form with th:action.
# 403 on the first POST from a single-page app
# Spring Security 6 masks and lazily loads the CSRF token, so the cookie is missing or unreadable.
# -> use CsrfTokenRequestAttributeHandler with setCsrfRequestAttributeName(null).
# Roles missing from a Keycloak ID token
# Realm roles are in the access token by default.
# -> enable "Add to ID token" on the mapper, or read the access token.
# Hardening checklist
# - Link accounts by (provider, subject); link by email only if verified
# - Use https everywhere; exact redirect URIs, no wildcards
# - Keep tokens on the server; never return them in API responses
# - Authorize with your own roles, not provider scopes
# - Enable PKCE for confidential clients if the provider supports it
# - Log org.springframework.security.oauth2 at DEBUG when diagnosing