Spring Boot OAuth2 Social Login: React — Part 3
Spring Boot 13 min read Part of Spring Security with React
The browser half of the flow: why the OAuth2 redirect cannot be an XHR call, reading the token out of the redirect URI exactly once, and the allow-list on the backend that makes the whole round trip safe.
Part 2 ended with a backend that completes an OAuth2 login and issues a JWT. This part is the client that starts that flow and collects the token at the end of it.
The one thing that shapes every decision here: the OAuth2 redirect cannot be an XHR call. The provider’s consent screen has to be shown to the user in the address bar, so the browser leaves your application entirely and comes back, which means the client cannot hold state across the round trip and the token has to arrive in the URL.
Written against React 18, React Router 7 and Vite 5.
Tooling note
The original of this series used create-react-app. CRA is no longer maintained and the React
documentation no longer recommends it; Vite is the direct replacement and the code below is identical
under either.
npm create vite@latest polling-app -- --template react
cd polling-app && npm install react-router-dom
The one difference that matters: environment variables are import.meta.env.VITE_* rather than
process.env.REACT_APP_*, and they are still baked in at build time either way.
Starting the flow: a link, not fetch
const API_BASE = import.meta.env.VITE_API_BASE_URL; // http://localhost:8080
const REDIRECT = `${window.location.origin}/oauth2/redirect`;
export default function Login() {
return (
<div className="login">
<a className="btn btn-google"
href={`${API_BASE}/oauth2/authorize/google?redirect_uri=${REDIRECT}`}>
Log in with Google
</a>
<a className="btn btn-github"
href={`${API_BASE}/oauth2/authorize/github?redirect_uri=${REDIRECT}`}>
Log in with GitHub
</a>
</div>
);
}
An <a href>, not an onClick with fetch. A fetch to /oauth2/authorize/google returns a 302 to
Google, the browser follows it inside the XHR, and either CORS blocks it or you receive Google’s
consent page as a string, never a logged-in user. The navigation has to be a real one.
redirect_uri is where the backend sends the browser once it has a token. It is a parameter rather
than a constant so the same backend serves several front ends, and it is exactly why part 2 put an
allow-list behind it.
Collecting the token
The backend appends the token to the redirect URI, so the landing route reads it from the query string:
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
export default function OAuth2RedirectHandler({ onAuthenticated }) {
const [params] = useSearchParams();
const navigate = useNavigate();
useEffect(() => {
const token = params.get('token');
const error = params.get('error');
if (token) {
localStorage.setItem('accessToken', token);
onAuthenticated();
navigate('/', { replace: true });
} else {
navigate('/login', { replace: true, state: { error: error ?? 'Login failed' } });
}
}, [params, navigate, onAuthenticated]);
return <p>Signing you in…</p>;
}
replace: true is load-bearing. Without it the redirect URI, containing the token, stays in
the browser history, so the back button re-enters it and the token sits in history entries and in the
referrer of anything the next page loads. Replacing the entry removes it from the stack; the token is
still briefly in the address bar, which is a known cost of this pattern.
The useEffect runs twice in React 18’s development StrictMode. It is idempotent here, writing the
same token twice is harmless, but that is worth checking deliberately rather than discovering when
a non-idempotent effect fires twice in development and once in production.
Routing
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/oauth2/redirect" element={<OAuth2RedirectHandler onAuthenticated={load} />} />
<Route element={<RequireAuth authenticated={authenticated} loading={loading} />}>
<Route path="/" element={<Home currentUser={currentUser} />} />
<Route path="/profile" element={<Profile currentUser={currentUser} />} />
</Route>
</Routes>
React Router 7 uses element rather than component, Routes rather than Switch, and expresses a
protected area as a layout route with an <Outlet />:
import { Navigate, Outlet, useLocation } from 'react-router-dom';
function RequireAuth({ authenticated, loading }) {
const location = useLocation();
if (loading) return <Spinner />; // not yet known — do NOT redirect
if (!authenticated) return <Navigate to="/login" replace state={{ from: location }} />;
return <Outlet />;
}
The loading branch is the bug most guards ship with. On a page reload the current user is not known
until /user/me answers, and a guard that treats “not yet loaded” as “not authenticated” bounces
every logged-in user to the login screen on every refresh.
Attaching the token
const API_BASE = import.meta.env.VITE_API_BASE_URL;
async function request({ url, method = 'GET', body }) {
const headers = new Headers({ 'Content-Type': 'application/json' });
const token = localStorage.getItem('accessToken');
if (token) headers.append('Authorization', `Bearer ${token}`);
const response = await fetch(API_BASE + url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 401) {
localStorage.removeItem('accessToken');
window.location.href = '/login';
return;
}
const json = await response.json();
if (!response.ok) throw new Error(json.message ?? 'Request failed');
return json;
}
export const getCurrentUser = () => request({ url: '/user/me' });
One function that every call goes through, so the token header and the 401 handling exist once. A
component calling fetch directly is how an endpoint ends up unauthenticated by accident.
Bearer with the trailing space: a token concatenated without it produces a 401 that looks like an
expired token rather than a malformed header.
The CORS and cookie details
Two backend settings from part 2 show up here as client behaviour.
The API and the front end are different origins in development, localhost:5173 and
localhost:8080, so the backend’s CorsConfigurationSource must list the front-end origin. A
missing entry surfaces as a CORS error on /user/me, not on the login redirect, because the redirect
is a navigation and navigations are not subject to CORS.
The authorization request is stored in a cookie rather than the session, which is what makes the
backend stateless across the round trip to the provider. If that cookie is lost (a SameSite=Strict
setting, or a provider redirect arriving as a cross-site POST) the callback fails with
authorization_request_not_found. SameSite=Lax is the working value.
Why the allow-list is not optional
The client sends redirect_uri and the backend echoes the token to it. Without validation, anyone
can send a victim to:
https://api.example.com/oauth2/authorize/google?redirect_uri=https://attacker.example/steal
The victim logs in legitimately, and your backend delivers a valid token to the attacker’s host. That is account takeover, and it is a property of the flow rather than of a mistake in the client, which is why the check lives on the server, where a client cannot skip it.
Part 2 has the authorizedRedirectUris property and the comparison; the client’s only obligation is
to send a URI that is on the list.
Logging out
function logout() {
localStorage.removeItem('accessToken');
window.location.href = '/';
}
That is all a client can do, and it is worth being honest about what it does not do: the JWT stays valid until it expires. There is no server-side session to invalidate, which is the trade a stateless token makes. Short expiry plus a refresh token is the usual answer; a deny-list of revoked tokens reintroduces the state the design removed.
That completes the series: part 1 for the entities and configuration, part 2 for the security layer. More in the Spring Boot guides.
Frequently asked questions
Why can’t I start the OAuth2 flow with fetch?
The provider’s consent screen must render in the browser’s address bar. An XHR either gets blocked by CORS or receives the consent HTML as a string, and the user never logs in. Use a real navigation.
Why does the token arrive in the URL?
The browser leaves the application entirely during the flow, so there is no open connection to return it on. The redirect URI is the only channel back.
How do I keep the token out of browser history?
Navigate away with replace: true as soon as it
is read. It is still momentarily in the address bar, an unavoidable cost of this pattern.
Why does my app redirect to login on every refresh?
The auth guard treats “current user not yet
loaded” as “not authenticated”. Add an explicit loading state and render nothing until /user/me
answers.
Why does the effect that reads the token run twice?
React 18 StrictMode double-invokes effects in development. Storing the same token twice is harmless; check that any other effect you add there is idempotent too.
Where should the token be stored?
localStorage is simple and readable by any script, so one XSS
flaw exposes it. An HttpOnly cookie prevents that and requires CSRF protection. Choose knowingly and
keep the token short-lived.
Why do I get a CORS error on /user/me but not on login?
The login redirect is a navigation, which CORS does not apply to. The API call is cross-origin, so the backend must list the front-end origin.
What causes authorization_request_not_found?
The cookie holding the authorization request was not
returned on the callback. SameSite=Lax is required; Strict drops it on the provider’s redirect.
Why must the backend validate redirect_uri?
Without an allow-list, an attacker can have a legitimately issued token delivered to a host they control. That is account takeover, and the client cannot enforce it.
How do I log out server-side?
You cannot, with a plain JWT. It stays valid until it expires. Short expiry plus refresh tokens, or a revocation list that reintroduces server state.