Frontend Dev – Authentication and Security in the Browser

August 17, 20267 min readUpdated 8/20/2026

Start from the fact that decides everything else in this post: every line of code you ship to the browser is public, and every request it makes can be forged. Your bundle can be read, your JavaScript can be edited live, and anyone can send whatever they like straight to your API with a command-line tool and no browser involved.

So a frontend cannot enforce anything. What it can do is make the right thing easy, keep secrets it was given from leaking, and not hand attackers a way in. That is a real job — it is just not the job of being the security boundary.

The frontend is not the security boundary

The demo app hides the admin area behind a role check, and says plainly what that check is worth:

NOTE: this is a usability guard, not a security control. Anyone can edit client-side JavaScript. The real enforcement is the backend rejecting requests without a valid ADMIN token.

Hiding the button is good product design — do not show people things they cannot do. It is worth zero as protection. The test for whether you have understood this: if someone deleted your entire frontend and called your API directly, would anything bad happen? If yes, the problem is on the server. The backend track's post is the other half of this and worth reading straight after.

Corollaries people get wrong:

  • Never filter sensitive data in the browser. If the API returns every user's record and you render only one, you have leaked all of them — the response is right there in the network tab.
  • Never trust a price the client sent. Re-read it from the database. The demo app's backend re-prices every cart on every read for exactly this reason.
  • A hidden field is not hidden. Neither is a disabled button.

How login actually works

The token flow, in five steps:

  1. The user submits credentials to POST /api/auth/login.
  2. The server checks them against a hash and returns a token plus the user.
  3. The browser stores the token.
  4. Every subsequent request attaches it: Authorization: Bearer <token>.
  5. The server verifies its signature and expiry on every request, and decides what this caller may do.

Step 4 is one place, in the API layer, never in a component:

  if (auth) {
    const token = tokenStore.get();
    if (token) headers.Authorization = `Bearer ${token}`;
  }

Note it is opt-in per call. Public endpoints do not need a token, and not sending one is one fewer place it can leak.

A JWT is signed, not secret

The most common misunderstanding about tokens. A JWT is base64 — anyone holding it can read its contents, including the user id and role, without any key at all. The signature stops it being modified, not read. So: never put anything in a token you would not show the user, and never trust a role you decoded client-side for anything but deciding what to render.

Where to put the token

This is the real decision, and both answers are defensible. The demo app writes the trade-off directly into the code rather than pretending there is a winner:

export const tokenStore = {
  get: (): string | null => localStorage.getItem(TOKEN_KEY),
  set: (token: string) => localStorage.setItem(TOKEN_KEY, token),
  clear: () => localStorage.removeItem(TOKEN_KEY),
};

⚠️ localStorage is readable by any JavaScript on the page, so a single XSS bug leaks the token. It is used here because it is simple, survives a refresh, and works identically for the React and Angular builds. A production app would prefer an HttpOnly cookie, which JavaScript cannot read at all — at the cost of needing a CSRF story.

localStorageHttpOnly cookie
Readable by JSYes — one XSS bug and it is goneNo
Sent automaticallyNo, you attach itYes — which is what enables CSRF
Needs CSRF protectionNoYes — SameSite, and usually a token
Survives refreshYesYes
Cross-origin APIStraightforwardNeeds CORS credentials configured

Neither option protects you if you have XSS — with a cookie the attacker cannot steal the token, but they can make requests as the user from your page, which is usually enough. The honest summary: HttpOnly cookies are the better default; not having XSS is what actually matters. And sessionStorage is not a security upgrade, it just forgets sooner.

Restoring a session on refresh

A stored token proves nothing — it may be expired or revoked. The only way to know is to ask:

      try {
        const me = await api.get<User>('/api/auth/me', { auth: true, signal: controller.signal });
        setUser(me);
      } catch {
        // Expired or invalid — drop it rather than leaving a dead token around.
        if (!controller.signal.aborted) tokenStore.clear();
      } finally {
        if (!controller.signal.aborted) setInitialising(false);
      }

The comment above it states the principle: /api/auth/me is the source of truth rather than anything cached in the browser. Never decide someone is signed in because there is a string in localStorage.

This is also where the initialising flag from post 8 comes from — the guard must wait for this check, or it redirects a valid session to the login page.

Logout, and the token that dies mid-session

Logging out means clearing the token and the user, and — importantly — clearing anything else personal you cached. Leaving the previous customer's order history in memory for the next person on a shared machine is a real leak.

Expiry mid-session is the case people forget. The user is happily clicking and suddenly everything returns 401. Handle it centrally in the API layer: on 401, clear the token and send them to login with a message saying the session expired. Handling it per call site guarantees some call site does not.

XSS, which is the one that will actually get you

Cross-site scripting is attacker JavaScript running on your page, with all your user's privileges. It reads localStorage, it makes authenticated requests, it rewrites the page.

The good news: modern frameworks escape interpolated values by default, so {userName} renders <script> as visible text rather than a script. You mostly get this for free — and you lose it the moment you deliberately opt out.

The escape hatches to treat as red flags:

  • dangerouslySetInnerHTML in React, v-html in Vue, innerHTML anywhere. If you must render HTML from elsewhere, sanitise it first with a real library such as DOMPurify — not a regex.
  • Putting user input into a href without checking the scheme. javascript: URLs execute.
  • eval, new Function, and passing strings to setTimeout.
  • Injecting a third-party script tag with a value that came from a URL parameter.

CSRF, in one paragraph

If your auth rides on a cookie, the browser attaches it to requests your app did not make — including one triggered by a form on someone else's site. That is cross-site request forgery. The defences are SameSite on the cookie (largely default now), a CSRF token the attacker cannot read, and never using GET for anything that changes data. If you send a token in an Authorization header instead, CSRF does not apply, because the attacker's page cannot add that header — which is the one genuine advantage of the localStorage approach.

The rest of the checklist

ItemWhy
HTTPS everywhereOtherwise the token is readable in transit. Non-negotiable.
No secrets in the bundleAnything in your frontend build is public. See post 12 — this is the mistake that leaks API keys.
Content-Security-PolicyTells the browser which script sources are allowed. Turns many XSS bugs into a blocked request.
Audit dependenciesnpm audit in CI. A compromised package runs with your app's full privileges.
rel="noopener"On target="_blank" links, so the opened page cannot touch yours. Modern browsers default it; older ones do not.
Do not log tokensThey end up in error-reporting tools and support screenshots.
Same error for wrong password and unknown emailDifferent messages let someone enumerate who has an account. The demo app's tests assert this.

That last one is worth seeing, because it is a security property expressed as a test:

test('an unknown email fails the same way — no account enumeration', async ({ page }) => {

What not to build yourself

Password hashing, password reset flows, multi-factor, OAuth, session management. These are solved, the failure modes are subtle, and the cost of getting one wrong is everything. Use your backend framework's implementation or an identity provider. Your job on the frontend is the login form, the token handling, the guards, and not leaking anything.

The one thing to take from this post

Assume an attacker has your entire frontend source, has deleted every guard in it, and is calling your API directly — because they can. Everything the frontend does is user experience. The security lives on the server, and the most valuable thing you can personally do about it is not shipping an XSS hole.

Next: Performance and Accessibility.