Frontend Security: XSS, CSRF, and Best Practices
A practical guide to frontend security — preventing cross-site scripting (XSS), cross-site request forgery (CSRF), securing authentication, and building defense in depth.
A practical guide to authentication for frontend developers — understanding OAuth flows, JWT structure, session cookies, and implementing secure auth in React applications.
By codefunc
Authentication is how your app knows who the user is. Authorization is what they can do. Frontend developers need to understand both — not to build auth systems from scratch, but to integrate them correctly. Misunderstanding token storage, OAuth flows, or session handling leads to security vulnerabilities.
Bottom line: Use OAuth 2.0 with PKCE for third-party login (Google, GitHub). Store tokens in HTTP-only cookies when possible, not localStorage. Understand that JWTs are signed but not encrypted — don't put secrets in them. For most apps, use an auth library (NextAuth, Auth0, Clerk) rather than implementing from scratch.
| Method | Best for | Storage | Security |
|---|---|---|---|
| Session cookies | Traditional web apps | Server-side | High |
| JWT in cookies | SPAs with same-origin API | HTTP-only cookie | High |
| JWT in memory | SPAs with cross-origin API | JavaScript variable | Medium |
| JWT in localStorage | Never recommended | localStorage | Low |
| OAuth 2.0 | Third-party login | Varies by flow | High |
JWTs are self-contained tokens that carry user information.
A JWT has three parts separated by dots:
header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4iLCJpYXQiOjE1MTYyMzkwMjJ9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header:
{
"alg": "HS256",
"typ": "JWT"
}
Payload:
{
"sub": "1234567890",
"name": "John",
"iat": 1516239022,
"exp": 1516242622
}
Signature: HMAC-SHA256 of header + payload with secret key.
Use the JWT Decoder to inspect tokens during development.
// Storing JWT (in memory - preferred for SPAs)
let accessToken: string | null = null;
export function setToken(token: string) {
accessToken = token;
}
export function getToken() {
return accessToken;
}
// Using with fetch
async function fetchProtectedData() {
const response = await fetch('/api/data', {
headers: {
'Authorization': `Bearer ${getToken()}`,
},
});
return response.json();
}
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
});
api.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Token expired - try to refresh
const newToken = await refreshToken();
if (newToken) {
error.config.headers.Authorization = `Bearer ${newToken}`;
return api.request(error.config);
}
}
return Promise.reject(error);
}
);
let refreshPromise: Promise<string> | null = null;
async function refreshToken(): Promise<string | null> {
// Prevent multiple simultaneous refresh requests
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Send refresh token cookie
})
.then((res) => res.json())
.then((data) => {
setToken(data.accessToken);
return data.accessToken;
})
.catch(() => {
// Refresh failed - redirect to login
window.location.href = '/login';
return null;
})
.finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
Sessions store auth state on the server, sending only a session ID to the client.
// Login - credentials sent, cookie received automatically
async function login(email: string, password: string) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Important: include cookies
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
return response.json();
}
// Protected request - cookie sent automatically
async function fetchProfile() {
const response = await fetch('/api/profile', {
credentials: 'include',
});
return response.json();
}
// Logout
async function logout() {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
}
| Aspect | Sessions | JWTs |
|---|---|---|
| State | Server stores session | Token is self-contained |
| Scalability | Needs shared session store | Stateless, scales easily |
| Revocation | Immediate (delete session) | Hard (wait for expiry) |
| Size | Small cookie (~32 bytes) | Larger (~500+ bytes) |
| Cross-domain | Complex (CORS) | Easier with Bearer header |
OAuth lets users log in with existing accounts (Google, GitHub, etc.).
| Flow | Use case | Security |
|---|---|---|
| Authorization Code + PKCE | SPAs, mobile apps | High |
| Authorization Code | Server-side apps | High |
| Implicit | Deprecated, don't use | Low |
| Client Credentials | Machine-to-machine | High |
PKCE (Proof Key for Code Exchange) protects against authorization code interception.
// 1. Generate code verifier and challenge
function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
}
function base64UrlEncode(buffer: Uint8Array): string {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// 2. Start OAuth flow
async function startOAuthFlow() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store verifier for later
sessionStorage.setItem('code_verifier', codeVerifier);
const params = new URLSearchParams({
client_id: 'your-client-id',
redirect_uri: 'http://localhost:3000/callback',
response_type: 'code',
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: crypto.randomUUID(), // CSRF protection
});
window.location.href = `https://provider.com/oauth/authorize?${params}`;
}
// 3. Handle callback
async function handleOAuthCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const codeVerifier = sessionStorage.getItem('code_verifier');
// Exchange code for tokens
const response = await fetch('https://provider.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: 'your-client-id',
code: code!,
redirect_uri: 'http://localhost:3000/callback',
code_verifier: codeVerifier!,
}),
});
const tokens = await response.json();
// tokens.access_token, tokens.id_token, tokens.refresh_token
}
Most apps should use a library rather than implementing OAuth manually.
NextAuth.js (Next.js):
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
});
// Usage in component
import { signIn, signOut, auth } from '@/auth';
export default async function Page() {
const session = await auth();
if (!session) {
return <button onClick={() => signIn('google')}>Sign in</button>;
}
return (
<div>
<p>Welcome, {session.user?.name}</p>
<button onClick={() => signOut()}>Sign out</button>
</div>
);
}
| Location | XSS vulnerable | CSRF vulnerable | Recommendation |
|---|---|---|---|
| localStorage | Yes | No | Never for auth |
| sessionStorage | Yes | No | Avoid |
| HTTP-only cookie | No | Yes (mitigable) | Preferred |
| Memory (variable) | No | No | Good for SPAs |
// Server sets cookie
res.cookie('token', jwt, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
// Frontend just includes credentials
fetch('/api/protected', {
credentials: 'include',
});
For SPAs with cross-origin APIs:
// Auth context
const AuthContext = createContext<{
isAuthenticated: boolean;
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
} | null>(null);
export function AuthProvider({ children }) {
const [token, setToken] = useState<string | null>(null);
const login = async (credentials: Credentials) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
const { accessToken } = await response.json();
setToken(accessToken);
};
const logout = () => {
setToken(null);
};
return (
<AuthContext.Provider value={{
isAuthenticated: !!token,
login,
logout,
}}>
{children}
</AuthContext.Provider>
);
}
// ProtectedRoute component
function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return <LoadingSpinner />;
}
if (!isAuthenticated) {
return null;
}
return children;
}
// Usage
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
</Routes>
);
}
| Mistake | Risk | Fix |
|---|---|---|
| JWT in localStorage | XSS can steal tokens | Use HTTP-only cookies |
| No CSRF protection | Forged requests | SameSite cookies, CSRF tokens |
| Long-lived access tokens | Extended compromise window | Short access + refresh tokens |
| Secrets in JWT payload | Information disclosure | Only put public claims |
| No token refresh | Poor UX on expiry | Implement silent refresh |
| Trusting JWT without verification | Token tampering | Always verify signature |
Use these codefunc tools alongside this guide:
Use established auth libraries (NextAuth, Auth0, Clerk) rather than building from scratch. Store tokens in HTTP-only cookies when possible. For SPAs that can't use cookies, store in memory and implement token refresh. Always use HTTPS. Understand that JWTs are signed (tamper-proof) but not encrypted (readable). Keep access tokens short-lived (15 minutes) with longer-lived refresh tokens.
A practical guide to frontend security — preventing cross-site scripting (XSS), cross-site request forgery (CSRF), securing authentication, and building defense in depth.
Decoding a JWT is not verifying it. Learn how signature verification works, the alg:none and algorithm-confusion attacks to avoid, which claims to check, and how to inspect tokens safely in development.
A practical guide to animations in React — CSS transitions and keyframes, Framer Motion for complex interactions, performance optimization, and accessibility considerations.
Find a developer tool