---
title: "Auth Sessions: Best Practices"
slug: "auth-sessions"
category: "backend"
tags: ["auth", "sessions", "security", "cookies", "csrf"]
status: "stable"
last_updated: 2026-06-16
summary: "Store session tokens in HttpOnly cookies, rotate session IDs on login, rotate refresh tokens with reuse detection, and add CSRF defenses."
related: ["[[comparisons/oauth-vs-jwt]]", "[[backend/supabase]]", "[[backend/supabase-rls]]", "[[ops/secrets-and-env]]", "[[coding/python-security]]", "[[backend/webhooks]]", "[[ai-agents/mcp-security]]"]
---

## Overview

Authenticate users with an opaque session token delivered in an `HttpOnly`, `Secure`, `SameSite` cookie, and keep the source of truth server-side. The browser should never read the token from JavaScript, the cookie should never travel over plain HTTP, and the server should be able to revoke a session immediately. The choice of session format (opaque server session vs JWT) is its own decision; see [[comparisons/oauth-vs-jwt]] for when each wins. This page is the house standard for the cookie flags, rotation, and CSRF defenses that apply regardless of format.

## Set every session cookie with HttpOnly, Secure, and SameSite

`HttpOnly` blocks `document.cookie` access, which is mandatory to stop session theft via XSS. `Secure` keeps the cookie off unencrypted HTTP. `SameSite` controls cross-site sending: `Strict` is the safest and blocks the cookie on all cross-site requests; `Lax` (the browser default) allows it on top-level GET navigations, which keeps inbound links working. Prefer `Strict` for pure app sessions; use `Lax` when external links must land logged in. Scope with `Path=/` and bound the lifetime with `Max-Age`.

```
Set-Cookie: __Host-session=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=3600
```

The `__Host-` prefix forces `Secure`, `Path=/`, and no `Domain`, which hardens the cookie against subdomain injection ([OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)). Use a session ID of at least 128 bits from a CSPRNG.

## Rotate the session ID on every login

Issue a fresh session ID after each successful authentication and after any privilege change. This defeats session fixation, where an attacker plants a known session ID and waits for the victim to log in under it. OWASP requires regenerating the ID on login and recommends periodic rotation during long sessions.

## Rotate refresh tokens and revoke the family on reuse

Pair a short-lived access token (commonly 15 to 30 minutes) with a longer-lived refresh token. On each refresh, issue a new refresh token and invalidate the old one (single-use rotation). Track tokens as a family per session. If a refresh token is presented after it was already rotated, treat that replay as a compromise: revoke the entire family, invalidate active access tokens, and force re-authentication. RFC 9700 (OAuth 2.0 Security Best Current Practice, January 2025) codifies rotation and reuse detection. Serialize concurrent refreshes so parallel requests do not trigger false-positive reuse.

## Store Supabase tokens in HttpOnly cookies, never localStorage

With Supabase Auth, do server-side session handling: keep the access and refresh tokens in `HttpOnly` cookies set by your server, not in `localStorage` where XSS can read them. In the current `@supabase/ssr` package you pass a `cookies` object to `createServerClient` that implements `getAll()` and `setAll(cookiesToSet)`; use only that pair and never the older `get`/`set`/`remove` methods, which are deprecated. Supabase rotates refresh tokens on use. Never ship the `service_role` key to the browser or any client bundle; it bypasses Row Level Security. Keep `service_role` server-side only (see [[ops/secrets-and-env]]) and enforce per-user access with [[backend/supabase-rls]]. See [[backend/supabase]] for project setup.

## Add CSRF defenses when cookies authenticate requests

`SameSite` cookies reduce CSRF exposure but are not a complete defense; older browsers and some navigation paths weaken it. For state-changing requests authenticated by cookies, add an anti-CSRF token. Use the synchronizer token pattern or a double-submit cookie, and validate it on every `POST`, `PUT`, `PATCH`, and `DELETE` ([OWASP CSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)). Token-in-header auth without a cookie sidesteps CSRF but reintroduces XSS token-theft risk, which is why HttpOnly cookies remain the house default.

## Make logout actually revoke the session

Logout must delete the server-side session record, not just clear the cookie. With opaque sessions, drop the row or cache entry so the token is dead immediately. With Supabase, call sign-out so the refresh token is revoked server-side and clear the cookies. Apply the same revocation path when you detect refresh-token reuse, a password change, or a reported compromise. Webhook and machine callers follow a separate model; see [[backend/webhooks]]. Agent and MCP credential handling is covered in [[ai-agents/mcp-security]].

## Related

- [[comparisons/oauth-vs-jwt]] - opaque sessions vs JWTs and why not to use JWT as a session store
- [[backend/supabase]] - Supabase project and auth setup
- [[backend/supabase-rls]] - per-user access control that `service_role` bypasses
- [[ops/secrets-and-env]] - keeping `service_role` and signing keys server-side
- [[coding/python-security]] - server-side input and credential handling
- [[backend/webhooks]] - authenticating machine callers without browser cookies
- [[ai-agents/mcp-security]] - credential handling for agents and MCP servers
