Skip to content
CalliCoder

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.

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.

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.