Skip to content
Huseyin Babal
Go back

Google login with Spring Security and React, without a token in the browser

Most “Sign in with Google” tutorials for single-page apps end with an access token in localStorage. Any script that runs on the page can read it from there: your own code, a compromised npm package, an injected ad. RFC 10017 (OAuth 2.0 for Browser-Based Applications, IETF, August 2026) compares three ways to build OAuth for browser apps and lists them from most to least secure. The first one is a backend for frontend (BFF): a server-side component runs the OAuth flow, keeps the tokens, and gives the browser only a session cookie that JavaScript can’t read. The RFC says a browser client that holds tokens itself is exposed to every attack scenario it describes once an attacker can run code in the page.

This post builds that first design with tools most Java teams already have:

PartVersionJob
React + ViteReact 19.3, Vite 8.3UI, and a dev proxy so the browser sees one origin
Spring Boot4.1.2 on Java 27API, and the OAuth client (Spring Security oauth2Login)
GoogleOpenID Connectlogin, ID token, access token
PostgreSQL17our own users table, managed by Flyway

The flow in one table

#From → toWhat travels
1browser → Spring (through Vite)GET /oauth2/authorization/google
2Spring → browser302 to Google with state, nonce and a PKCE code_challenge
3browser ↔ Googlethe user picks an account and agrees
4Google → browser → Spring302 to /login/oauth2/code/google?code=…&state=…
5Spring → Google (server to server)the code, the client secret and the PKCE verifier
6Google → Springaccess token, ID token (and a refresh token if asked for)
7Spring → Postgresinsert or update the user, keyed by Google’s sub
8Spring → browserSet-Cookie: JSESSIONID (HttpOnly) and XSRF-TOKEN
9browser → Springfetch('/api/orders'), the cookie is added by the browser

React never sees the authorization code, the client secret or any Google token. Steps 5 and 6 don’t touch the browser at all.

Two projects

spring init --type=maven-project --boot-version=4.1.2 --java-version=27 \
  --dependencies=web,security,oauth2-client,jdbc,postgresql,flyway api
npm create -y vite@latest web -- --template react-ts --no-interactive

Create React App was deprecated on 14 February 2025; Vite’s react-ts template is the usual replacement.

The Google side

In Google Cloud: create a project, open Google Auth Platform, fill in the consent screen (app name, support email, audience External, contact email), then create an OAuth client of type Web application with one authorized redirect URI:

http://localhost:5173/login/oauth2/code/google

The port is Vite’s, not Spring’s. The browser only ever talks to localhost:5173, and the proxy below forwards /login/** to Spring, so the redirect has to land on the origin the browser knows.

Google keeps a new app in Testing mode, where sign-in is limited to the accounts on the test-user list, so add yours there. Put the client ID and secret in an env file that is not committed:

# api/.env  (chmod 600, listed in .gitignore)
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...

Spring Boot

application.yaml:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5544/orders
    username: orders
    password: orders
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid,profile,email
server:
  port: 8081
  servlet:
    session:
      cookie:
        same-site: strict
  forward-headers-strategy: framework

There’s no provider: block. Spring Security ships Google as a known provider (CommonOAuth2Provider.GOOGLE), so the authorization, token, JWK and user-info URLs are already filled in. openid in the scope is what turns this into OpenID Connect: Google returns an ID token, and Spring validates its signature, issuer, audience and nonce.

forward-headers-strategy: framework matters because of the proxy. Vite sends X-Forwarded-* headers (xfwd: true below), and with this setting Spring builds the redirect URI from them, so it says localhost:5173 and matches what Google has on file.

SecurityConfig.java:

@Configuration
class SecurityConfig {

    @Bean
    SecurityFilterChain security(HttpSecurity http, AppUserService users) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/**").authenticated()
                .anyRequest().permitAll())
            .oauth2Login(login -> login
                .userInfoEndpoint(info -> info.oidcUserService(users))
                .defaultSuccessUrl("/", true))
            .logout(logout -> logout.logoutSuccessUrl("/"))
            .exceptionHandling(e -> e.defaultAuthenticationEntryPointFor(
                new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
                PathPatternRequestMatcher.withDefaults().matcher("/api/**")))
            .csrf(csrf -> csrf.spa());
        return http.build();
    }
}

What each part does:

React and the proxy

vite.config.ts:

// every auth and API path goes to Spring Boot, so the browser sees one origin
const spring = { target: 'http://localhost:8081', xfwd: true }

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: { '/api': spring, '/oauth2': spring, '/login': spring, '/logout': spring },
  },
})

This block is why the cookies work. The page and the API share the origin localhost:5173, so a SameSite=Strict session cookie is sent on every fetch, and there is no CORS configuration to get wrong.

The parts of App.tsx that touch auth:

useEffect(() => {
  fetch('/api/me')
    .then(r => (r.ok ? r.json() : null))
    .then(async me => {
      setUser(me)
      if (me) setOrders(await fetch('/api/orders').then(r => r.json()))
    })
    .finally(() => setLoading(false))
}, [])

// not logged in: a plain link, the browser navigates to Spring
<a className="google" href="/oauth2/authorization/google"><GoogleLogo />Sign in with Google</a>

// logout is a POST, so it carries the CSRF header
function csrfToken() {
  return document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''
}
async function logout() {
  await fetch('/logout', { method: 'POST', headers: { 'X-XSRF-TOKEN': decodeURIComponent(csrfToken()) } })
  window.location.href = '/'
}

No OAuth library, no token storage, no Authorization header. The sign-in button is a link because the login is a series of full-page redirects, and a fetch can’t show Google’s consent screen.

What Spring keeps, and where

ThingWhere it lives (Spring Boot 4.1.2 defaults)Sent to the browser?
Logged-in user (OidcUser, with the ID token’s claims)HTTP session → SecurityContextNo, only the session ID
Access token, refresh tokenInMemoryOAuth2AuthorizedClientService, server memoryNo
Client secretenvironment variableNo
Session IDJSESSIONID cookie, HttpOnly, SameSite=StrictYes, unreadable by JS
CSRF tokenXSRF-TOKEN cookie, readable on purposeYes

A controller gets the user straight from the session:

@GetMapping("/me")
Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
    Long id = db.sql("SELECT id FROM users WHERE provider = 'google' AND subject = :sub")
        .param("sub", user.getSubject())
        .query(Long.class)
        .single();
    return Map.of("id", id, "name", user.getFullName(), "email", user.getEmail());
}

getSubject(), getEmail() and getFullName() read claims from the ID token that Google signed. If the API later needs to call a Google API for the user, the access token is one @RegisteredOAuth2AuthorizedClient("google") parameter away, still on the server.

The browser attaches cookies by itself. If evil.example makes the user’s browser send POST localhost:5173/logout, the session cookie goes along. SameSite=Strict already blocks that in current browsers, and RFC 10017 still requires the BFF to have a real CSRF defense on top. Here that defense is the double-submit token: another site can make the browser send our cookie, but it can’t read it, so it can’t copy the value into the header.

curl -s -c jar -o /dev/null localhost:5173/api/me
curl -s -b jar -X POST -o /dev/null -w "%{http_code}\n" localhost:5173/logout
curl -s -b jar -H "X-XSRF-TOKEN: $(grep XSRF jar | cut -f7)" -X POST -o /dev/null -w "%{http_code}\n" localhost:5173/logout
403
302output

Same cookies both times. Without the header Spring answers 403; with it, the logout goes through.

Our own users table

Postgres runs in Compose (api/compose.yaml, published on 5544):

name: orders
services:
  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: orders
      POSTGRES_USER: orders
      POSTGRES_PASSWORD: orders
    ports:
      - "5544:5432"

Flyway migration V1__users.sql:

CREATE TABLE users (
    id            BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    provider      TEXT        NOT NULL,
    subject       TEXT        NOT NULL,
    email         TEXT        NOT NULL,
    name          TEXT,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_login_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (provider, subject)
);

The key is (provider, subject). Google’s documentation tells you to use sub as the user’s unique ID: it never changes, while the email address on an account can. There is no token column; the table only records who the person is.

The insert happens inside the login, in a subclass of Spring’s own OIDC user service:

@Service
class AppUserService extends OidcUserService {

    private final JdbcClient db;

    AppUserService(JdbcClient db) {
        this.db = db;
    }

    @Override
    public OidcUser loadUser(OidcUserRequest request) {
        OidcUser user = super.loadUser(request);
        db.sql("""
                INSERT INTO users (provider, subject, email, name)
                VALUES (:provider, :subject, :email, :name)
                ON CONFLICT (provider, subject)
                DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name, last_login_at = now()
                """)
            .param("provider", request.getClientRegistration().getRegistrationId())
            .param("subject", user.getSubject())
            .param("email", user.getEmail())
            .param("name", user.getFullName())
            .update();
        return user;
    }
}

super.loadUser has already validated the ID token at this point, so the claims can be trusted. If the insert throws, the login doesn’t complete, so nobody ends up logged in without a row in users.

Run it and check

docker compose -f api/compose.yaml up -d
cd api && set -a && . ./.env && set +a && ./mvnw spring-boot:run
cd web && npm run dev

Without a session, the API answers 401, and the response already carries the two cookies (some headers trimmed):

curl -i localhost:5173/api/orders
HTTP/1.1 401 Unauthorized
set-cookie: XSRF-TOKEN=3717c096-...; Path=/
set-cookie: JSESSIONID=4EA68FCC...; Path=/; HttpOnly; SameSite=Strict
x-content-type-options: nosniff
x-frame-options: DENY
content-length: 0output

The redirect Spring builds for step 2 (client ID, state, nonce and challenge replaced here):

curl -s -o /dev/null -D - localhost:5173/oauth2/authorization/google | grep -i '^location'
location: https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=<client-id>&scope=openid%20profile%20email&state=<state>&redirect_uri=http://localhost:5173/login/oauth2/code/google&nonce=<nonce>&code_challenge=<challenge>&code_challenge_method=S256output

After signing in through the browser, DevTools → Application shows empty Local Storage and Session Storage. The only cookie document.cookie returns is XSRF-TOKEN. In Postgres, select provider, subject, email, last_login_at from users returns one row for the account that signed in.

Before production

From RFC 10017’s BFF section and from the defaults above:

Sources


Share this post:

Previous Post
AWS IAM for a new account: root MFA, a budget, and an access key that needs a phone
Next Post
One Spring Boot jar on five JVMs: Temurin, Corretto, Zulu, Semeru and GraalVM under load