Spring Boot
Google Login
Google sign-in is OpenID Connect (OIDC) layered on OAuth2, and Spring Boot ships a ready-made Google provider, so a working login needs only a client id and secret. The real work is what happens after Google answers: mapping the verified identity to a user in your own database, deciding when it is safe to link it to an existing account, and handling the profile picture. This entry covers Google Cloud setup, configuration, a custom OidcUserService, prompt and domain restrictions, profile pictures, Google One Tap for SPAs, and the errors you are most likely to hit.
How the Flow Works and What You Set Up
With oauth2Login, the browser is sent to Google, the user signs in and consents, and Google redirects back to your app with a one-time code. Spring Security exchanges that code for tokens on the server, validates the ID token's signature, issuer, audience and expiry against Google's published keys, and builds an OidcUser from its claims. Your application never sees the user's Google password, and the client secret never leaves the server.
Setup in Google Cloud Console has four parts (menu names move around between console redesigns, so look for the equivalents). Create or pick a project. Configure the consent screen: choose External for a public app or Internal if everyone is in your Google Workspace, and keep the scopes to openid, email and profile. While the app is in Testing status only the test users you list can sign in, which is the usual cause of "Access blocked" errors; publish the app when you are ready, and since those three scopes are non-sensitive, no review is needed. Then create an OAuth client of type Web application. Finally add the authorized redirect URI, which for Spring Security is your base URL plus /login/oauth2/code/google, for example http://localhost:8080/login/oauth2/code/google locally and https://app.example.com/login/oauth2/code/google in production. Google compares it character for character, including scheme, host, port and trailing slash.
Spring Boot already knows Google's endpoints, default scopes (openid, profile, email) and the default redirect URI template, so the configuration is short. Keep the secret in an environment variable or a secret store, never in the repository.
yaml
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
# Not needed, because these are the defaults for the google registration:
# scope: openid, profile, email
# redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
# Behind a reverse proxy or load balancer terminating TLS, tell Spring to trust the
# X-Forwarded-* headers. Otherwise {baseUrl} resolves to http://... and Google
# rejects the redirect with redirect_uri_mismatch.
server:
forward-headers-strategy: frameworkReading the Google Claims
The ID token carries a small set of standard OIDC claims. Two rules save you from most account bugs. First, identify a person by the sub claim, never by email: sub is permanent for that Google account, while an email address can change, and Workspace admins can reassign one to someone else. Second, treat everything except sub as optional. A user may have no picture or no full name, so handle nulls, and do not trust an email unless email_verified is true.
A small record that normalises the claims keeps the rest of the code simple, and it lets the redirect login and the One Tap flow (further down) share the same provisioning code. The hosted domain claim, hd, is only present for Google Workspace accounts, which is what you check if you want to restrict sign-in to one organisation.
Java
public record GoogleProfile(
String sub,
String email,
boolean emailVerified,
String name,
String pictureUrl,
String hostedDomain) {
// From the redirect (oauth2Login) flow:
public static GoogleProfile from(OidcUser user) {
return new GoogleProfile(
user.getSubject(),
user.getEmail(),
Boolean.TRUE.equals(user.getEmailVerified()),
user.getFullName(),
user.getPicture(),
user.getClaimAsString("hd"));
}
// From the One Tap flow (verified payload, see the last sections):
public static GoogleProfile from(GoogleIdToken.Payload p) {
return new GoogleProfile(
p.getSubject(),
p.getEmail(),
Boolean.TRUE.equals(p.getEmailVerified()),
(String) p.get("name"),
(String) p.get("picture"),
p.getHostedDomain());
}
}
// What Google sends (OidcUser accessors in brackets):
// sub stable, unique id for the account [getSubject()]
// email the address, can change over time [getEmail()]
// email_verified true for virtually all Google accounts [getEmailVerified()]
// name display name, may be absent [getFullName()]
// given_name / family_name [getGivenName() / getFamilyName()]
// picture URL on googleusercontent.com, may be absent [getPicture()]
// locale e.g. "en" [getLocale()]
// hd hosted domain, Workspace accounts only [getClaimAsString("hd")]
// iss, aud, iat, exp validated for you by Spring SecurityProvisioning the Application User
After Google vouches for an identity, you need an account of your own to attach roles and data to. The usual model is two tables: users, and social_accounts that records (provider, provider_id) pairs pointing at a user. Look the person up by that pair first, since sub is the reliable key. Only when there is no match do you consider email.
Linking a new Google login to an existing account just because the email matches is the classic account takeover hole. If someone registered locally with alice@example.com and never verified that address, an attacker who controls a Google account with that email gets Alice's account. So link automatically only when the existing account's email was verified, and otherwise stop and ask the user to sign in the old way and connect Google from their settings.
Put a unique constraint on (provider, provider_id) so two simultaneous first logins cannot create duplicates; the loser gets a constraint violation instead of a second account. The method runs in a transaction, so changing a managed entity, like refreshing the picture URL, is saved on commit without an explicit save call.
Java
@Entity
@Table(name = "social_accounts",
uniqueConstraints = @UniqueConstraint(columnNames = {"provider", "provider_id"}))
public class SocialAccount {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(nullable = false) private String provider; // "google"
@Column(name = "provider_id", nullable = false) private String providerId; // the sub claim
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private AppUser user;
protected SocialAccount() { }
public SocialAccount(String provider, String providerId, AppUser user) {
this.provider = provider; this.providerId = providerId; this.user = user;
}
public AppUser getUser() { return user; }
}
@Service
public class GoogleAccountService {
private final AppUserRepository users;
private final SocialAccountRepository socialAccounts;
public GoogleAccountService(AppUserRepository users, SocialAccountRepository socialAccounts) {
this.users = users;
this.socialAccounts = socialAccounts;
}
@Transactional
public AppUser findOrCreate(GoogleProfile profile) {
AppUser user = socialAccounts.findByProviderAndProviderId("google", profile.sub())
.map(SocialAccount::getUser)
.orElseGet(() -> createOrLink(profile));
// Managed entity: this is written on commit, no save() needed.
if (profile.pictureUrl() != null) {
user.setPictureUrl(profile.pictureUrl());
}
return user;
}
private AppUser createOrLink(GoogleProfile profile) {
AppUser user = users.findByEmailIgnoreCase(profile.email())
.map(existing -> {
if (!existing.isEmailVerified()) {
// Never merge into an account whose email nobody has proven ownership of.
throw new AccountConflictException(
"An account with this email exists. Sign in with your password, then link Google.");
}
return existing;
})
.orElseGet(() -> users.save(AppUser.newSocialUser(
profile.email(), profile.name(), profile.pictureUrl()))); // verified, role USER
socialAccounts.save(new SocialAccount("google", profile.sub(), user));
return user;
}
}Plugging It into Spring Security with OidcUserService
Extend OidcUserService, call super.loadUser to get Google's verified identity, run your provisioning, and return an OidcUser whose authorities come from your database. Without that last step the principal only has Google's default authorities (OIDC_USER and SCOPE_*), and your hasRole checks will never pass.
Do not make your JPA entity implement OidcUser. It is tempting, but the ID token is not a persistent field, so the entity ends up returning nulls, and a principal stored in the HTTP session needs to be Serializable, which entities usually are not. Subclassing DefaultOidcUser avoids both problems: it is serializable, keeps the real ID token (which the logout handler needs later), and you only add the extra fields you want, such as your own user id.
Setting the name attribute key to sub makes principal.getName() return the stable Google id. If you register other OIDC providers, this service is called for them too, so check the registration id before applying Google-specific logic.
Java
public class AppOidcUser extends DefaultOidcUser {
private final Long userId;
public AppOidcUser(Long userId, Collection<? extends GrantedAuthority> authorities,
OidcIdToken idToken, OidcUserInfo userInfo) {
super(authorities, idToken, userInfo, "sub"); // name attribute = sub
this.userId = userId;
}
public Long getUserId() { return userId; }
}
@Service
public class GoogleOidcUserService extends OidcUserService {
private final GoogleAccountService accounts;
@Value("${app.google.allowed-domain:}") // empty means any Google account
private String allowedDomain;
public GoogleOidcUserService(GoogleAccountService accounts) {
this.accounts = accounts;
}
@Override
public OidcUser loadUser(OidcUserRequest request) throws OAuth2AuthenticationException {
OidcUser google = super.loadUser(request); // token already validated by Spring
GoogleProfile profile = GoogleProfile.from(google);
if (!profile.emailVerified()) {
throw new OAuth2AuthenticationException(
new OAuth2Error("email_not_verified"), "Google email is not verified");
}
// The hd parameter sent to Google is only a UI hint. This server-side check is the real restriction.
if (!allowedDomain.isBlank() && !allowedDomain.equals(profile.hostedDomain())) {
throw new OAuth2AuthenticationException(
new OAuth2Error("domain_not_allowed"), "Sign-in is limited to " + allowedDomain);
}
AppUser user;
try {
user = accounts.findOrCreate(profile);
} catch (AccountConflictException ex) {
throw new OAuth2AuthenticationException(
new OAuth2Error("account_conflict"), ex.getMessage(), ex);
}
List<GrantedAuthority> authorities = user.getRoles().stream()
.<GrantedAuthority>map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.toList();
return new AppOidcUser(user.getId(), authorities, google.getIdToken(), google.getUserInfo());
}
}
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http,
GoogleOidcUserService oidcUserService,
OAuth2AuthorizationRequestResolver requestResolver) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/error", "/login/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(ep -> ep.authorizationRequestResolver(requestResolver))
.userInfoEndpoint(ep -> ep.oidcUserService(oidcUserService)))
.logout(logout -> logout.logoutSuccessUrl("/"));
return http.build();
}
}
// In a controller, the logged-in user is injected like this:
// @GetMapping("/me")
// String me(@AuthenticationPrincipal AppOidcUser user) { return "user " + user.getUserId(); }Forcing the Account Chooser and Limiting to a Domain
By default Google signs the user straight in if they have one active session, which surprises people on shared machines and anyone with several accounts. Sending prompt=select_account makes Google show the account chooser every time. Spring Boot has no registration property for extra authorization parameters (a common misconception, since extra-params looks like it should exist), so you add them by customising the authorization request resolver.
For a Workspace-only app you can also send hd=yourcompany.com, which makes Google pre-filter the account chooser to that domain. Treat it purely as a convenience, because a user can edit the URL and remove it. The enforcement is the server-side claim check in the OidcUserService above.
Java
@Bean
OAuth2AuthorizationRequestResolver authorizationRequestResolver(
ClientRegistrationRepository clientRegistrations) {
var resolver = new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrations, "/oauth2/authorization");
resolver.setAuthorizationRequestCustomizer(customizer -> customizer
.additionalParameters(params -> {
params.put("prompt", "select_account"); // always show the account chooser
// params.put("hd", "example.com"); // UI hint only: still check the claim server-side
}));
return resolver;
}Profile Pictures
The picture claim is a URL on googleusercontent.com, not an image you own. Two things are worth knowing. Loading these URLs directly from your own pages can fail intermittently with 403 or 429 responses when a page embeds many of them or sends a referrer, and the simple fix is a no-referrer policy on the image tag. And the URL accepts a size suffix, so you can ask Google for a small version rather than downloading a large image to display at 48 pixels; check the result for your own URLs, since old-style links use a different parameter.
Storing the URL (as the provisioning code does, refreshing it on every login) is fine for an avatar. If the image matters to your product, or you cannot tolerate it disappearing or rate-limiting, download it once on first login and serve your own copy from your object storage. Never assume the user has a picture: render initials or a default avatar when the field is null.
html
<!-- Avatar: no-referrer avoids most hotlinking failures; a size suffix avoids huge downloads. -->
<img src="https://lh3.googleusercontent.com/a/ACg8oc...=s96-c"
referrerpolicy="no-referrer"
width="48" height="48"
alt=""
loading="lazy">
<!-- Thymeleaf, with a fallback when the user has no picture: -->
<img th:if="${user.pictureUrl != null}" th:src="${user.pictureUrl}"
referrerpolicy="no-referrer" width="48" height="48" alt="">
<span th:unless="${user.pictureUrl != null}" th:text="${#strings.substring(user.name, 0, 1)}"></span>Google One Tap for Single-Page Apps
Redirect login suits server-rendered apps. A single-page app with its own API often prefers Google's Sign in with Google and One Tap, where the browser obtains a signed ID token (the credential) directly from Google and posts it to your backend. There is no redirect and no Spring OAuth2 client involved, so the backend has one job: verify the token and treat it as proof of identity.
Verification must check the signature against Google's keys, the issuer, expiry, and above all the audience, which must equal your own client id; skipping the audience check would accept tokens issued to someone else's app. Google's GoogleIdTokenVerifier does all of that and caches the keys, so create it once and reuse it rather than building a new one per request. The controller then reuses the same GoogleAccountService as the redirect flow, so both paths create and link users identically, and returns your own application tokens. Pick one session model per app: the redirect flow gives you an HTTP session, while One Tap with your own JWTs is stateless.
Add the google-api-client dependency (current version from Maven Central; it brings the verifier and the JSON factory).
Java
// pom.xml:
// <dependency>
// <groupId>com.google.api-client</groupId>
// <artifactId>google-api-client</artifactId>
// </dependency>
@Component
public class GoogleTokenVerifier {
private final GoogleIdTokenVerifier verifier;
public GoogleTokenVerifier(@Value("${GOOGLE_CLIENT_ID}") String clientId) {
this.verifier = new GoogleIdTokenVerifier.Builder(
new NetHttpTransport(), GsonFactory.getDefaultInstance())
.setAudience(List.of(clientId)) // reject tokens issued for any other app
.build();
}
public GoogleIdToken.Payload verify(String credential) {
try {
GoogleIdToken token = verifier.verify(credential); // signature, issuer, expiry, audience
if (token == null) {
throw new BadCredentialsException("Invalid Google credential");
}
return token.getPayload();
} catch (GeneralSecurityException | IOException ex) {
throw new AuthenticationServiceException("Could not verify Google credential", ex);
}
}
}
@RestController
@RequestMapping("/api/v1/auth/google")
public class GoogleAuthController {
public record OneTapRequest(@NotBlank String credential) { }
private final GoogleTokenVerifier verifier;
private final GoogleAccountService accounts;
private final JwtService jwtService; // your own token issuer
public GoogleAuthController(GoogleTokenVerifier verifier, GoogleAccountService accounts,
JwtService jwtService) {
this.verifier = verifier;
this.accounts = accounts;
this.jwtService = jwtService;
}
@PostMapping("/one-tap")
public ResponseEntity<AuthResponse> oneTap(@RequestBody @Valid OneTapRequest request) {
GoogleProfile profile = GoogleProfile.from(verifier.verify(request.credential()));
if (!profile.emailVerified()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
AppUser user = accounts.findOrCreate(profile); // same logic as the redirect flow
return ResponseEntity.ok(jwtService.issueTokens(user));
}
}
// If the browser POSTs the credential to your server through a redirect (data-login_uri) instead of a
// JavaScript callback, Google also sends a g_csrf_token cookie and body field. Compare them before
// verifying the credential.Troubleshooting
redirect_uri_mismatch: the URI Spring sends is not an exact match for one registered in the console. Compare the one shown in Google's error page with your list, character by character. Typical causes are http versus https behind a proxy (set server.forward-headers-strategy), localhost versus 127.0.0.1, a different port, or a trailing slash.
Access blocked, or "has not completed the verification process": the consent screen is in Testing status and the account is not on the test user list. Add the user or publish the app.
invalid_client or 401 during the token exchange: the client id or secret is wrong or has stray whitespace, often from a trailing newline in an environment variable.
The user lands back on the login page in a loop: the callback succeeded but the session is not kept. Check that the session cookie is not being dropped (SameSite and Secure settings behind a proxy) and that your custom principal is serializable.
A null email: the email scope was not requested, usually because you overrode the scope list and left it out.
For anything else, turn on debug logging and read the exact error that Spring reports for the token exchange or the ID token validation.
yaml
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.oauth2: TRACE # shows the token exchange and validation errors