Skip to main content

Implementing Authentication Without Exposing Credentials

React applications must authenticate users without exposing credentials or access tokens to client-side code. The correct pattern is to send credentials (email/password or OAuth code) to your backend, receive an httpOnly cookie (which the browser automatically includes in requests but JavaScript cannot read), and use that cookie for authenticated API calls. This guide covers session-based authentication, JWT tokens with refresh patterns, OAuth2 integration, and why storing tokens in localStorage is a liability.

The Problem: Where to Store Access Tokens

Many developers store access tokens in localStorage or React state:

// UNSAFE: Token is accessible to JavaScript
async function login(email, password) {
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ email, password })
});

const data = await response.json();
localStorage.setItem("accessToken", data.accessToken); // Any XSS attack can steal this
setAuthToken(data.accessToken);
return data;
}

If an XSS vulnerability exists anywhere on your site, an attacker can read this token with localStorage.getItem("accessToken") and make authenticated requests on behalf of the user. The token is valid for hours (or longer), giving the attacker a persistent attack window.

Secure Pattern: httpOnly Cookies

The correct approach is to use httpOnly cookies set by your backend. An httpOnly cookie is:

  • Automatically included in every HTTP request to your domain
  • Inaccessible to JavaScript (even malicious scripts cannot read it with document.cookie)
  • Bound to the domain and path you specify
  • Protected by the Secure flag (HTTPS only) and SameSite flag (CSRF protection)
// Backend (Node.js/Express)
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";

app.post("/api/login", async (req, res) => {
const { email, password } = req.body;

// Validate credentials
const user = await User.findOne({ email });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: "Invalid email or password" });
}

// Create a session token (JWT or session ID)
const token = jwt.sign({ userId: user.id, email: user.email }, process.env.JWT_SECRET, {
expiresIn: "15m"
});

// Set as httpOnly cookie
res.cookie("sessionToken", token, {
httpOnly: true, // JavaScript cannot access this
secure: true, // HTTPS only
sameSite: "strict", // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
path: "/" // Available on entire domain
});

res.json({ userId: user.id, email: user.email }); // No token in response
});

React frontend does not need to do anything special. The browser automatically includes the sessionToken cookie in subsequent requests:

// React (frontend)
async function login(email, password) {
const response = await fetch("https://api.example.com/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", // Include cookies
body: JSON.stringify({ email, password })
});

if (response.ok) {
const user = await response.json();
setUser(user); // User object, not token
navigate("/dashboard");
}
}

// Subsequent API calls automatically include the cookie
async function fetchUserProfile() {
const response = await fetch("https://api.example.com/api/profile", {
credentials: "include" // Cookie is sent automatically
});
return response.json();
}

The server reads the cookie from every request and validates it:

// Backend middleware
function requireAuth(req, res, next) {
const token = req.cookies.sessionToken;

if (!token) {
return res.status(401).json({ error: "Not authenticated" });
}

try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ error: "Invalid token" });
}
}

// Protected route
app.get("/api/profile", requireAuth, (req, res) => {
res.json({ userId: req.user.userId, email: req.user.email });
});

Refresh Token Rotation Pattern

For long-lived sessions, use a two-token pattern: a short-lived access token (httpOnly cookie) and a longer-lived refresh token (also httpOnly, separate cookie). When the access token expires, use the refresh token to get a new one without requiring the user to log in again:

// Backend login endpoint
app.post("/api/login", async (req, res) => {
const user = await authenticateUser(req.body.email, req.body.password);

// Short-lived access token (15 minutes)
const accessToken = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
expiresIn: "15m"
});

// Long-lived refresh token (7 days)
const refreshToken = jwt.sign({ userId: user.id, tokenVersion: user.tokenVersion }, process.env.JWT_REFRESH_SECRET, {
expiresIn: "7d"
});

// Both as httpOnly cookies
res.cookie("accessToken", accessToken, { httpOnly: true, secure: true, sameSite: "strict", maxAge: 15 * 60 * 1000 });
res.cookie("refreshToken", refreshToken, { httpOnly: true, secure: true, sameSite: "strict", maxAge: 7 * 24 * 60 * 60 * 1000 });

res.json({ userId: user.id });
});

// Refresh endpoint: use refresh token to get a new access token
app.post("/api/refresh", (req, res) => {
const refreshToken = req.cookies.refreshToken;

if (!refreshToken) {
return res.status(401).json({ error: "No refresh token" });
}

try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);

// Verify the token hasn't been invalidated (token rotation)
const user = await User.findById(decoded.userId);
if (user.tokenVersion !== decoded.tokenVersion) {
return res.status(401).json({ error: "Token revoked" });
}

// Issue a new access token
const newAccessToken = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
expiresIn: "15m"
});

res.cookie("accessToken", newAccessToken, { httpOnly: true, secure: true, sameSite: "strict", maxAge: 15 * 60 * 1000 });
res.json({ success: true });
} catch (error) {
res.status(401).json({ error: "Invalid refresh token" });
}
});

React can trigger a refresh automatically when an API call returns 401 (Unauthorized):

// React API wrapper with auto-refresh
async function apiCall(url, options = {}) {
let response = await fetch(url, { ...options, credentials: "include" });

if (response.status === 401) {
// Token expired, try to refresh
const refreshResponse = await fetch("https://api.example.com/api/refresh", {
method: "POST",
credentials: "include"
});

if (refreshResponse.ok) {
// Retry the original request with the new token
response = await fetch(url, { ...options, credentials: "include" });
} else {
// Refresh failed, redirect to login
window.location.href = "/login";
}
}

return response;
}

// Usage
async function fetchProfile() {
const response = await apiCall("https://api.example.com/api/profile");
return response.json();
}

OAuth2 with Authorization Code Flow

OAuth2 (used by Google, GitHub, Microsoft login) removes the need to handle passwords directly. The flow is:

  1. User clicks "Sign in with Google"
  2. Browser redirects to Google's login page
  3. User logs in with Google, Google redirects back to your app with an authorization code
  4. Your backend exchanges the code for an access token (done server-to-server, not in React)
  5. Backend creates a session and returns httpOnly cookie
// Backend OAuth callback endpoint
app.get("/api/auth/callback", async (req, res) => {
const { code } = req.query;

// Exchange code for access token (server-to-server, keeps client ID/secret safe)
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
body: new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET, // Secret stays on server
redirect_uri: "https://myapp.com/api/auth/callback",
grant_type: "authorization_code"
})
});

const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;

// Use access token to fetch user info
const userResponse = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
headers: { Authorization: `Bearer ${accessToken}` }
});

const googleUser = await userResponse.json();

// Find or create local user
let user = await User.findOne({ googleId: googleUser.id });
if (!user) {
user = await User.create({
googleId: googleUser.id,
email: googleUser.email,
name: googleUser.name
});
}

// Create session cookie
const sessionToken = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: "7d" });
res.cookie("sessionToken", sessionToken, { httpOnly: true, secure: true, sameSite: "strict" });

// Redirect to app (now authenticated via cookie)
res.redirect("https://myapp.com/dashboard");
});

React initiates OAuth by redirecting to the authorization URL:

// React component
function LoginPage() {
function handleGoogleLogin() {
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.append("client_id", process.env.REACT_APP_GOOGLE_CLIENT_ID);
authUrl.searchParams.append("redirect_uri", "https://myapp.com/api/auth/callback");
authUrl.searchParams.append("response_type", "code");
authUrl.searchParams.append("scope", "openid email profile");

window.location.href = authUrl.toString(); // Redirect to Google
}

return (
button onClick={handleGoogleLogin}>
Sign in with Google
/button>
);
}

Logout and Session Invalidation

Logout requires clearing the session cookie and, optionally, invalidating the token server-side:

// Backend logout endpoint
app.post("/api/logout", (req, res) => {
// Increment tokenVersion to invalidate all tokens
const userId = req.user.userId;
User.findByIdAndUpdate(userId, { $inc: { tokenVersion: 1 } });

// Clear cookies
res.clearCookie("sessionToken");
res.clearCookie("refreshToken");

res.json({ success: true });
});

// React logout
async function handleLogout() {
await fetch("https://api.example.com/api/logout", {
method: "POST",
credentials: "include"
});

setUser(null);
navigate("/login");
}

Key Takeaways

  • Never store access tokens in localStorage; use httpOnly cookies instead.
  • httpOnly cookies are inaccessible to JavaScript and protect against XSS.
  • Use the refresh token pattern for longer sessions without re-authentication.
  • OAuth2 with Authorization Code flow removes the need to handle passwords.
  • Always validate credentials and tokens on the backend.
  • Set secure (HTTPS only), sameSite=strict (CSRF protection), and httpOnly flags on all session cookies.

Frequently Asked Questions

Can I use localStorage for non-sensitive tokens like refresh tokens?

No. Even "refresh" tokens grant access and should be treated as secrets. localStorage is vulnerable to XSS. Use httpOnly cookies for all sensitive tokens.

What if my backend and frontend are on different domains?

Use credentials: "include" in fetch and configure CORS with Access-Control-Allow-Credentials: true and Access-Control-Allow-Origin (not *). Cookies will be sent across domains, but this requires careful setup.

Is JWT better than session-based authentication?

Both have tradeoffs. JWTs are stateless and scalable; sessions require a database lookup. For most React apps, httpOnly JWT cookies are ideal: stateless like JWT, secure like sessions.

How do I protect against CSRF attacks with cookies?

Set the sameSite=strict flag on your cookie. This prevents the cookie from being sent in cross-origin requests (e.g., attacker's website). Also implement CSRF tokens for state-changing operations (POST, DELETE).

What if the user closes their browser—are they logged out?

If you set maxAge (session duration), the cookie persists across browser restarts. Set it to a short value (15 minutes) and use refresh tokens for longer sessions. On logout, clear the cookies.

Further Reading