← Orieb AlzyuotHire me
By Orieb Alzyuot · 12 October 2026 · 15 min read

Front-End Security in 2026: Cookies, Tokens, CAPTCHA and the Things We Get Wrong

Most front-end breaches aren’t clever. A token sits where any script can read it, an innerHTML renders user input, or a “secret” ships in the bundle. Here’s how I harden web apps, and what to check if you build your own CAPTCHA.

Cover: Front-end security in 2026, with Set-Cookie and Content-Security-Policy headers, a shield with a lock, and a 3×3 CAPTCHA grid

Front-end engineers don’t own the whole security model, but we own more of it than we think. We decide where tokens live and what gets rendered as HTML. We choose which third-party scripts load, and what lands in the bundle. We also build the login, OTP and CAPTCHA screens attackers hit first.

This article covers the decisions I make on every product, and the mistakes I keep finding in reviews.

TL;DR

1. Where tokens live: localStorage vs HttpOnly cookies

This is the decision with the biggest blast radius. Here’s what happens with each choice when one XSS bug slips through.

Animation: an injected script reads an access token from localStorage and sends it to an attacker server; with an HttpOnly cookie, document.cookie returns nothing and the browser still attaches the cookie to API calls
Same injected script. With localStorage, the attacker gets a replayable token. With an HttpOnly cookie, there is nothing to read.

Token in localStorage or sessionStorage: any JavaScript running on your origin can call localStorage.getItem(). That includes an XSS payload, a compromised npm package or a hijacked analytics script. The token leaves the browser and can be replayed from the attacker’s own machine until it expires, long after the user has closed the tab.

Token in an HttpOnly cookie: the browser stores it and attaches it to matching requests, but no script can read it. document.cookie simply doesn’t include it. There is nothing to steal and nothing to replay elsewhere.

HTTP/1.1 204 No Content
Set-Cookie: __Host-session=9f2c…; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600
Set-Cookie: __Host-refresh=71ab…; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=1209600

The client code gets simpler too:

// No Authorization header, no token handling in JS
const res = await fetch('/api/me', { credentials: 'include' });

Be honest about the limit. HttpOnly turns “attacker steals a session” into “attacker can act as the user while the page is open”. That’s a big improvement, but it isn’t a fix. The XSS still runs, and it can call your API with the user’s cookies. That’s why sections 3 and 4 still matter.

The patterns I actually ship, best first:

  1. Same-site API or backend-for-frontend (BFF): the browser only ever holds an HttpOnly session cookie, and the BFF holds any OAuth tokens on the server.
  2. Access token in memory + refresh token in an HttpOnly cookie. The access token is short-lived and kept in a JavaScript variable (not storage). The refresh cookie is scoped to the refresh endpoint and rotated on every use.
  3. Both tokens in HttpOnly cookies, if a migration makes (1) hard for now.

Mobile apps are different: there’s no cookie jar to lean on, so use the platform’s secure storage (Keychain on iOS, Keystore-backed storage on Android).

If your API is on another origin, CORS must allow credentials explicitly:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

You can’t use a * origin with credentials. Allow-list exact origins, and never reflect whatever Origin arrives.

Try the difference yourself. This demo only reads its own sandboxed values and sends nothing anywhere:

▶ Try it live: https://oriebalzyuot96.github.io/blog/front-end-security-2026/#demo-xss

2. CSRF: the price of cookies

Cookies are sent automatically, and that’s the point. It’s also the risk: another site can make the user’s browser send a request to your API, cookies included. That’s cross-site request forgery.

Sequence diagram: login sets an HttpOnly SameSite cookie; JavaScript cannot read it; legitimate requests carry the cookie plus a CSRF token and pass Origin checks; a cross-site form post is blocked
JavaScript can't see the cookie. SameSite, Origin checks and a CSRF token decide which requests count.

Layer your defences:

// Express-style middleware for state-changing routes
const ALLOWED = new Set(['https://app.example.com']);

export function csrfGuard(req, res, next) {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();

  const site = req.get('Sec-Fetch-Site');
  if (site && site !== 'same-origin' && site !== 'same-site') return res.sendStatus(403);

  const origin = req.get('Origin');
  if (!origin || !ALLOWED.has(origin)) return res.sendStatus(403);

  const token = req.get('X-CSRF-Token');
  if (!token || !timingSafeEqualStr(token, req.session.csrfToken)) return res.sendStatus(403);

  next();
}

3. XSS hygiene: where frameworks stop protecting you

Angular, React, Vue and Svelte escape text by default. XSS comes back through the escape hatches:

When you really must render HTML, sanitise it with a maintained library:

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(userHtml, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'br'],
  ALLOWED_ATTR: ['href'],
});
function safeHref(raw: string): string {
  try {
    const u = new URL(raw, location.origin);
    return ['https:', 'mailto:'].includes(u.protocol) ? u.href : '#';
  } catch {
    return '#';
  }
}

Review rule: every innerHTML-style sink needs a comment explaining where the HTML comes from and how it’s sanitised. Lint for the rest.

4. CSP and Trusted Types: the safety net

A Content Security Policy limits what can run even when a bug lets markup in. Allow-list-based policies tend to be bypassable. The current guidance is a strict, nonce-based policy:

Content-Security-Policy:
  script-src 'nonce-r4nd0mPerResponse' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  form-action 'self';
  require-trusted-types-for 'script';
  trusted-types app-sanitizer;
  report-to csp
Reporting-Endpoints: csp="https://reports.example.com/csp"

Trusted Types stop DOM XSS at the sink: once the policy is enforced, el.innerHTML = string throws unless the value came from a policy you defined:

const policy = window.trustedTypes?.createPolicy('app-sanitizer', {
  createHTML: (input) => DOMPurify.sanitize(input),
});

el.innerHTML = policy ? policy.createHTML(userHtml) : DOMPurify.sanitize(userHtml);

Chromium has supported Trusted Types for years, and other engines have been adding them. The directive is ignored where it isn’t supported, so it’s safe to ship as defence in depth.

5. Subresource Integrity

If you load anything from a CDN, pin its hash, so a compromised CDN file doesn’t run:

<script
  src="https://cdn.jsdelivr.net/npm/some-lib@4.2.1/dist/some-lib.min.js"
  integrity="sha384-…exact hash of that file…"
  crossorigin="anonymous"></script>

6. Clickjacking

If your app can be framed, an attacker can overlay it with a decoy and trick users into clicking “Approve”. Send both headers: the modern directive, plus the legacy one for old clients:

Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY

Use frame-ancestors 'self' https://partner.example if you really need to be embedded. Remember: this doesn’t work in a meta tag.

7. Encrypting route params: what it does and doesn’t do

Several products I’ve worked on encrypt ids in URLs, so /projects/10 becomes /projects/Xk3…. It has real benefits:

But be clear about what it doesn’t do:

Two practical gotchas: keep OAuth callback params (code, state) in plain text, or the provider flow breaks. Old bookmarks with plain ids should still resolve, and still be authorized on the server.

8. Login hardening and rate limiting

9. Supply chain hygiene

Your bundle runs every dependency’s code with your users’ sessions.

10. Secrets never belong in front-end env files

VITE_*, NEXT_PUBLIC_*, PUBLIC_* and Angular’s environment.ts are inlined into the bundle. Anyone can open DevTools and read them.

11. CAPTCHA: choosing one, or building your own

A CAPTCHA is a speed bump, not a wall. It raises the cost of automated abuse on login, sign-up, password reset and contact forms. It should sit on top of rate limiting, never replace it.

Comparison cards for reCAPTCHA v2, reCAPTCHA v3, hCaptcha, Cloudflare Turnstile and a custom CAPTCHA across effort, user friction, privacy, accessibility and control
No free lunch: privacy, friction, accessibility and control trade off differently.

Google reCAPTCHA: v2 vs v3

hCaptcha and Cloudflare Turnstile

Building a custom CAPTCHA

Teams build their own for privacy (no third party sees their users), for brand and Arabic/RTL fit, or because the threat is modest and the UX matters more. I’ve shipped one: small visual puzzles on a 3×3 board (“tap the odd shape out”, “tap the biggest shape”), where the client only ever sends back one cell index. It’s perfectly reasonable, if you get the server side right. The design rule fits in one sentence: the board and the answer live on the server, and the client only sends back a token and a move.

Sequence diagram of custom CAPTCHA server verification: the client requests a challenge, the API rate-limits by IP and stores the answer with a TTL, the client submits token and move, the API deletes the token before checking, verifies in order and returns a short-lived grant
The server issues the challenge, keeps the answer, and redeems each token exactly once.

What to check in a custom CAPTCHA:

The heart of the verification, in TypeScript (Node + Redis):

const MIN_SOLVE_MS = 800;

export async function verifyCaptcha(clientIp: string, body: { token: string; kind: string; move: unknown }) {
  // 1. Redeem first: one token, one attempt. GETDEL is atomic.
  const raw = await redis.getDel(`captcha:${body.token}`);
  if (!raw) return fail('expired');                         // also covers replay

  const c = JSON.parse(raw) as { kind: string; board: number[]; answer: number; issuedAt: number; ipHash: string };

  // 2. Same caller, same game
  if (c.ipHash !== hashIp(clientIp)) return fail('mismatch');
  if (c.kind !== body.kind) return fail('mismatch');

  // 3. Humans aren't that fast
  if (Date.now() - c.issuedAt < MIN_SOLVE_MS) return fail('too-fast');

  // 4. Validate the move, then check it against the stored board
  const move = body.move;
  if (typeof move !== 'number' || !Number.isInteger(move) || move < 0 || move > 8) return fail('invalid');
  if (!isWinningMove(c, move)) return fail('wrong');

  return ok({ grant: await issueShortGrant(clientIp) });
}

And the response that issues the challenge is built by hand, so the answer never travels:

return {
  token,
  kind: entry.kind,
  board: entry.board,          // what to draw
  expiresInSeconds: TTL_SECONDS,
  // no `answer`, no `success`, nothing the client could replay
};

The checklist

Security on the front end is mostly about refusing convenient shortcuts. Every item above is boring, and that’s exactly why attackers count on us skipping them.


About the author

Orieb Alzyuot (عريب الزيوت) is a Senior Front-End & UI/UX Engineer based in Amman, Jordan, with 6+ years of experience building government, fintech, HR and enterprise products used every day. Her work includes:

Open to senior and lead front-end roles, contracts and freelance work, remote or with relocation.

Find me here:

If this helped, follow me on Medium for more on design systems, accessibility, Angular, React and front-end architecture. 💜