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:
| Part | Version | Job |
|---|---|---|
| React + Vite | React 19.3, Vite 8.3 | UI, and a dev proxy so the browser sees one origin |
| Spring Boot | 4.1.2 on Java 27 | API, and the OAuth client (Spring Security oauth2Login) |
| OpenID Connect | login, ID token, access token | |
| PostgreSQL | 17 | our own users table, managed by Flyway |
The flow in one table
| # | From → to | What travels |
|---|---|---|
| 1 | browser → Spring (through Vite) | GET /oauth2/authorization/google |
| 2 | Spring → browser | 302 to Google with state, nonce and a PKCE code_challenge |
| 3 | browser ↔ Google | the user picks an account and agrees |
| 4 | Google → browser → Spring | 302 to /login/oauth2/code/google?code=…&state=… |
| 5 | Spring → Google (server to server) | the code, the client secret and the PKCE verifier |
| 6 | Google → Spring | access token, ID token (and a refresh token if asked for) |
| 7 | Spring → Postgres | insert or update the user, keyed by Google’s sub |
| 8 | Spring → browser | Set-Cookie: JSESSIONID (HttpOnly) and XSRF-TOKEN |
| 9 | browser → Spring | fetch('/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:
/api/**needs a login, everything else (the login endpoints themselves) is open.oauth2Loginregisters the two filters behind steps 1–6: one that starts the redirect, one that handles the callback and exchanges the code. PKCE is on for this confidential client too; thecode_challenge_method=S256is visible in the redirect below.oidcUserService(users)plugs in our own class, which runs right after the ID token is validated (next section).- The 401 entry point. Without it, an unauthenticated
fetch('/api/orders')would get a302to Google, andfetchwould follow it and fail on CORS. With it,/api/**answers401and React decides what to show. csrf.spa()keeps CSRF protection on and makes it work for a JavaScript client: the token goes out in a readableXSRF-TOKENcookie, and Spring expects it back in anX-XSRF-TOKENheader on every state-changing request.
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
| Thing | Where it lives (Spring Boot 4.1.2 defaults) | Sent to the browser? |
|---|---|---|
Logged-in user (OidcUser, with the ID token’s claims) | HTTP session → SecurityContext | No, only the session ID |
| Access token, refresh token | InMemoryOAuth2AuthorizedClientService, server memory | No |
| Client secret | environment variable | No |
| Session ID | JSESSIONID cookie, HttpOnly, SameSite=Strict | Yes, unreadable by JS |
| CSRF token | XSRF-TOKEN cookie, readable on purpose | Yes |
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.
Why the CSRF cookie has to be there
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:
- Redirect URI on your real domain, over HTTPS, registered in Google Cloud.
- One site for UI and API. Serve both under one domain, or put a reverse proxy in front that does what the Vite proxy does here. Otherwise the cookie stops being first-party and CORS comes back.
- Cookie flags. The RFC says the session cookie MUST be
SecureandHttpOnly, SHOULD beSameSite=Strict, and SHOULD use the__Host-prefix. On HTTPS setserver.servlet.session.cookie.secure: true, and rename the cookie to__Host-SESSION(or similar) withserver.servlet.session.cookie.name. - Tokens survive a restart only if they’re stored somewhere.
InMemoryOAuth2AuthorizedClientServiceloses them when the process stops, and each instance has its own copy. With more than one instance, useJdbcOAuth2AuthorizedClientService(it ships with Spring Security) and a shared session store such as Spring Session. - Publish the Google app (leave Testing mode) before anyone outside your test-user list needs to sign in.