OAuth2 Login with Google and GitHub in Spring Boot 4.1
Sign in with Google and GitHub using oauth2Login on Spring Boot 4.1.1 and Spring Security 7.1.1: the redirects the app really builds, mapping OIDC and GitHub users to local accounts without keying on email, a 401 trap for non-browser clients, and an SPA plus backend setup with CORS, CSRF and logout. Five runnable Java files to download.
“Sign in with Google” looks like a one-line feature: add the starter, paste two properties, done. And the first sign-in usually does work. The trouble starts afterwards — when two providers must map onto one user table, when a GitHub user has no public email, when a single-page app on a different port has to call your API, and when /logout returns 403.
This post builds that setup on Spring Boot 4.1 and checks each claim with a real run. One limit up front, so the evidence is read correctly: nothing here signs in at the real Google or GitHub. That needs your own client credentials and a browser. What the code does run is the real Spring Security filter chain: the authorization redirects it builds, the 401/302 decisions, CSRF, CORS, and the account mapping logic fed with realistic ID-token and GitHub attributes.
Versions tested. Spring Boot 4.1.1 (GA September 2026) with Spring Security 7.1.1 and Spring Framework 7.0.9 as resolved by the Boot BOM, JDK 25.0.4.1 LTS (Temurin). Boot 4 splits starters differently from Boot 3: the dependency is spring-boot-starter-security-oauth2-client, and @AutoConfigureMockMvc now lives in org.springframework.boot.webmvc.test.autoconfigure — both confirmed by compiling the demo.
The five Java files below are named so they do not clash with other downloads on this site (every class is prefixed OAuth2Login); they all share package demo.
What oauth2Login() actually does
Your application never sees the user’s Google or GitHub password. Instead it sends the browser to the provider with a short request (“this app wants to know who this person is”), the user approves there, and the provider sends the browser back to your app with a one-time code. Your server then swaps that code for tokens in a direct call to the provider, learns who the user is, and creates a normal session cookie. After that, the rest of your app behaves as if the user had logged in the ordinary way.
The diagram marks the one split that matters for debugging: steps 1, 2 and 4 go through the browser (you can see them in the address bar), while step 5 is a private server-to-server call. When “login fails after I approve”, the cause is almost always in step 5 or 6, and the symptom is on the callback URL, not on the provider.
The smallest configuration
Two registrations, and nothing else is required to get the redirect flow. The defaults for Google and GitHub (authorize and token URLs, user-info endpoint) are built into Spring Security, so you only supply credentials and scopes. Google speaks OpenID Connect (it returns an ID token); GitHub speaks plain OAuth2 and you ask it for the user over an API call. That difference decides how you map users later.
The callback URL you register at each provider is /login/oauth2/code/{registrationId}, so http://localhost:8080/login/oauth2/code/google for Google while developing. Hitting the start URL for each provider with the placeholder credentials above shows exactly what the app sends (OAuth2LoginFlowTest.java, authorizationRedirects):
GET /oauth2/authorization/google -> 302
authorize endpoint : https://accounts.google.com/o/oauth2/v2/auth
response_type = code
client_id = demo-google-client-id
scope = openid profile email
redirect_uri = http://localhost/login/oauth2/code/google
has state=true has nonce=true has code_challenge=true
GET /oauth2/authorization/github -> 302
authorize endpoint : https://github.com/login/oauth/authorize
response_type = code
client_id = demo-github-client-id
scope = read:user user:email
redirect_uri = http://localhost/login/oauth2/code/github
has state=true has nonce=false has code_challenge=true
Three things to read from this. Both flows carry a state value (CSRF protection for the login itself) and a PKCE code_challenge. Only Google gets a nonce, because only OIDC has an ID token to bind it to. And redirect_uri is built from the request, so behind a reverse proxy it must come out as your public https:// address — the usual fix is server.forward-headers-strategy=framework and a proxy that sets X-Forwarded-* (documentation-sourced; not exercised here). The port is missing in the output only because MockMvc has no real port.
Going deeper: build file and registering the apps
The Maven dependencies used (parent spring-boot-starter-parent 4.1.1 supplies versions; java.version is 25):
Registering the credentials is done in each provider’s console and is not something this post executed: for Google, create an OAuth client of type “Web application” in Google Cloud Console and add the redirect URI above; for GitHub, create an OAuth App under Developer settings with the same callback. Keep the secrets in environment variables, as the ${GOOGLE_CLIENT_ID:…} placeholders do.
What an anonymous request gets — and the 401 that surprises curl users
With two providers configured, Spring Security generates a login page listing both. A browser asking for a protected page is redirected there. But a request without an Accept: text/html header is not, and neither is an API call:
GET /api/me (anonymous) -> 401
GET /dashboard, no Accept -> 401 Location: null
GET /dashboard, Accept html -> 302 Location: /login
GET / (anonymous) -> 200 public home page
generated /login page links -> [/oauth2/authorization/github, /oauth2/authorization/google]
The fingerprint of “my redirect works in the browser but not in my test or script”. The second and third lines are the same URL with and without the Accept: text/html header. In this configuration the redirect to /login is reserved for HTML requests; anything else falls through to a plain 401. My reading is that the delegating entry point falls back to the 401 entry point I registered for /api/** when no matcher fits — I did not confirm that from the source, only the observed behaviour. If your integration tests or curl checks expect a 302, send the header.
Mapping a provider user to a local account
After login, Spring gives you an OidcUser (Google) or an OAuth2User (GitHub). Your application almost certainly has its own user table, with its own ids and roles. The hook for bridging them is the user service on oauth2Login: you wrap the default one, then find-or-create the local account and return a principal that carries your roles.
http
.authorizeHttpRequests(a -> a
.requestMatchers("/", "/error").permitAll()
.anyRequest().authenticated())
.oauth2Login(o -> o
.defaultSuccessUrl(spaUrl, true) // always land back on the SPA
.userInfoEndpoint(u -> u
.oidcUserService(oidcMapper) // Google (OIDC)
.userService(githubMapper))) // GitHub (plain OAuth2)
/** OIDC providers (Google): the stable key is the ID token's "sub" claim. */
public OidcUser mapOidc(String registrationId, OidcUser oidc) {
Account a = findOrCreate(registrationId, oidc.getSubject(), oidc.getEmail(), oidc.getFullName());
return new DefaultOidcUser(withLocalRoles(oidc.getAuthorities(), a), oidc.getIdToken(), oidc.getUserInfo());
}
/** Plain OAuth2 (GitHub has no ID token): the stable key is the numeric "id" attribute, not "login". */
public OAuth2User mapGithub(OAuth2User user) {
String id = String.valueOf((Object) user.getAttribute("id"));
Account a = findOrCreate("github", id, user.getAttribute("email"), user.getAttribute("login"));
return new DefaultOAuth2User(withLocalRoles(user.getAuthorities(), a), user.getAttributes(), "id");
}
The account is keyed by (provider, subject). Feeding it the realistic cases — a Google user whose email changed, a GitHub user who renamed their login and has no public email:
google sub=g-777, email changed -> local accounts: 1
github id=4242, login renamed -> local accounts: 2
github email attribute -> null
authorities after mapping -> [ACCOUNT_102, ROLE_USER]
if keyed by email instead -> a third, duplicate-prone row: 3
The last line is a deliberately wrong lookup, shown only to make the point: a key that can be null or can change creates rows that do not belong to anyone. These lines are produced by accountMappingKeysOnStableId in OAuth2LoginFlowTest.java.
Never key a local account on email. A GitHub user can keep their email private, in which case the attribute is simply null (the third line above). Even when it is present, it can change, and an unverified email at a provider you do not control is how account-takeover bugs happen: someone registers your victim’s address at a lax provider and “logs in as” the existing local account. Use the provider’s immutable id — sub for OIDC, the numeric id for GitHub — and treat email as a contact detail, not an identity.
Going deeper: what the test harness does not cover
The test sessionLoginAndMeEndpoint uses Spring Security Test’s oidcLogin() to put a ready-made principal into the request. That is a shortcut around the user service: it proves /api/me serializes whatever principal exists, not that your mapper ran. That is why the mapping is tested by calling OAuth2LoginAccountService directly with a real OidcIdToken and GitHub-shaped attributes, and why the call that fetches the GitHub profile over HTTP (DefaultOAuth2UserService.loadUser) is wrapped but not exercised here.
GET /api/me (oidcLogin) -> 200 {"authorities":["ROLE_USER"],"principalType":"DefaultOidcUser","name":"g-1001"}
To exercise the whole mapper end to end, point the registration at a local mock provider (WireMock serving the token, JWKS and user-info endpoints) rather than the real one. The related post on running your own OAuth2/OIDC provider with Spring Authorization Server shows a provider you can run locally for exactly this.
A single-page app on another port
When the SPA is served from http://localhost:5173 and the backend from :8080, three things change. After login the user must land back on the SPA (defaultSuccessUrl(spaUrl, true); the true means “even if they started elsewhere”). API calls must get a 401 they can handle, not an HTML redirect to a login page — that is the defaultAuthenticationEntryPointFor block. And the browser must be allowed to send the session cookie cross-origin, which is CORS with credentials:
cfg.setAllowedOrigins(List.of(spaUrl.replaceAll("/$", "")));
cfg.setAllowedMethods(List.of("GET", "POST"));
cfg.setAllowedHeaders(List.of("X-XSRF-TOKEN", "Content-Type"));
cfg.setAllowCredentials(true); // lets the browser send the session cookie
OPTIONS /api/me from :5173 -> 200 Allow-Origin=http://localhost:5173 Allow-Credentials=true
OPTIONS /api/me from evil -> 403
Preflight from the SPA origin succeeds with credentials allowed; the same request from another origin is refused with 403 (corsPreflightFromSpa in OAuth2LoginFlowTest.java). On the browser side, fetch needs credentials: "include" for the cookie to travel at all.
Logout: why /logout is a 403
Because the session is a cookie, logout is a state-changing request and is protected by CSRF. With csrf(c -> c.spa()) (cookie-based token read by JavaScript and sent in a header), a bare POST is rejected:
POST /logout without CSRF -> 403
POST /logout with CSRF -> 302 Location: http://localhost:5173/
GET /logout -> 200
The 403 on a bare POST is the fingerprint of an SPA that calls logout without the token. And the third line is a different surprise: a GET to /logout does not log anyone out here, it returns Spring’s generated confirmation page — which is the safe behaviour, because a log-out link that works on GET can be triggered by any image tag on any website. These lines come from logoutNeedsCsrf. Note too what this logout does not do: it ends your app’s session only. The user stays signed in at Google or GitHub, so clicking “sign in” again will log them straight back in without a prompt.
Should you build this yourself?
Probably not for more than two providers. Each provider you add brings its own quirks (GitHub’s missing email, Google’s sub, Microsoft’s tenants), and every quirk lands in your mapping code. If you need several providers, enterprise SSO, or MFA policy, put a broker such as Keycloak in front and let Spring talk to one OIDC issuer. If you only need “is this person real”, consider passkeys instead — see passkeys and WebAuthn in Spring Security 7. And once login works, the questions about what happens to tokens and sessions are covered in JWT authentication in Spring Security 7.1.
Downloads
Put these five files in src/main/java/demo (and the test in src/test/java/demo) of a Spring Boot 4.1.1 project with the dependencies and application.yml shown above, then run mvn test:
No Comments yet!