How Auth Works
Identity, authentication, authorisation — the three primitives every auth system is built from
Authentication is the most-implemented feature in web development and also the most misunderstood. Not because it is technically complex, but because three distinct concepts get blurred together. Before writing a single line of code for Launchpad — the SaaS you will build across these eight lessons — you need to separate them clearly. Get this wrong at the design stage and no amount of correct implementation rescues you.
The three auth primitives
- Identity: who are you? — A unique identifier — email address, username, UUID. The stable anchor that persists across sessions. If two users share an identity, your system is broken at the foundation.
- Authentication: prove it — Verifying a secret only you should know — password, OTP, private key, biometric. The proof that the claimed identity is genuine. This is where most tutorial courses stop.
- Authorisation: what can you do? — Roles, permissions, and ownership rules. A verified user may still be blocked from a specific resource. A member of one org must never see data from another org — even though both are authenticated.
Think of an airport
Your passport establishes identity — who you are. The passport officer authenticates you — checks that the passport is real and belongs to you. The gate agent authorises you — confirms your boarding pass matches this flight, this seat, this destination. All three happen in sequence. Skipping one is how systems get breached. A passport without a gate check lets anyone who clears immigration board your flight.
The Launchpad auth map
Launchpad is a Next.js 15 App Router SaaS with email+password sign-in, GitHub OAuth, magic links, and multi-tenant organisations. Before writing code, draw every auth path on paper. Each lesson implements one piece of this map. Here is what you are building.
- Sign-up (email+password): user submits email + password → server hashes password with bcrypt → creates user row → issues session → sets HttpOnly cookie — Security boundary: the plain password must never reach a log, a database column, or a response body. Hash it before any I/O.
- Sign-in (email+password): server looks up user by email → verifies bcrypt hash → generates a new session ID → sets cookie — Security boundary: generate a brand-new session ID on every successful login. Reusing the pre-login session ID enables session fixation — the attacker who planted a known session ID before login can now use it authenticated.
- Sign-in (GitHub OAuth): your app redirects to GitHub with state + code_challenge → user authenticates with GitHub → GitHub redirects back with code → Supabase exchanges code for access token → fetches profile → creates or links user → issues session — Security boundary: the state parameter prevents CSRF (attacker redirecting a victim through a GitHub login they control). PKCE prevents authorization code interception.
- Magic link: user enters email → server generates 32-byte random token → hashes it → stores hash → emails the plain token → user clicks → server hashes incoming token and compares → issues session → deletes token — Security boundary: store the hash, not the plain token. Single-use: delete immediately on verification. If the database leaks, hashes cannot be reversed.
- Org invitation: admin sends invite → server generates a signed invite token using crypto.randomBytes(32) → stores hash with expiry → emails link → recipient clicks → server verifies hash and expiry → creates org_member row — Security boundary: token has a hard 7-day expiry. Storing a hash means a database leak does not let an attacker accept invitations.
- Protected route: Next.js middleware calls supabase.auth.getUser() → if no valid session, redirect to /sign-in?next=<path> → if valid, forward user context via request header → route handler reads the verified user ID from the header, never from the request body — Security boundary: the org_id must come from the server-side session, never from a query parameter or request body that a user can forge.
Sessions vs tokens — the fundamental choice
Every auth system stores proof that a user authenticated. The question is where that proof lives — on the server, or inside the token itself. This choice shapes your entire architecture.
- Stateful auth (sessions): server keeps the record — After login, the server creates a session record in Redis or a database. The client receives only an opaque session ID. To verify a request, the server looks up the ID. Delete the record and the user is immediately logged out — even if the session ID cookie still exists on the client.
- Stateless auth (JWTs): the token is self-contained — After login, the server creates a signed token containing the user ID and claims. Any server with the public key can verify it — no database lookup. Scaling is trivial. But there is no revocation: a valid JWT cannot be invalidated before its expiry time without introducing a blocklist — which reintroduces statefulness.
- Supabase uses both: short-lived JWTs plus a stateful refresh token — The access token is a JWT — stateless, 1-hour expiry, verifiable without a DB call. The refresh token is opaque, stored server-side, and can be revoked. This hybrid gives you the scaling benefits of JWTs without permanent irrevocability.
Cookies vs localStorage
- HttpOnly cookies: JavaScript-inaccessible by design — The browser sends the cookie automatically on every matching request, but JavaScript cannot read or modify it. This is the correct storage for session IDs and refresh tokens.
- localStorage: accessible from all page JavaScript — Convenient, fast, persistent across tabs. But any XSS payload — including third-party script injection — can read every key. Never store auth tokens here.
- Memory (JavaScript variable): safest, least persistent — Access tokens held in a module-level variable cannot be stolen by XSS after the initial page load. They are lost on refresh, but a secure HttpOnly refresh-token cookie can reissue them. This is the most XSS-resilient pattern for short-lived access tokens.
localStorage is the wrong place for auth tokens
localStorage is accessible from any JavaScript running on the page — including injected scripts from XSS attacks. A single XSS vulnerability can steal every token in localStorage silently. HttpOnly cookies are not accessible from JavaScript at all. document.cookie cannot read them. XSS cannot steal what JavaScript cannot access.
Try this
Map out the complete auth flow for Launchpad on paper before opening your editor. Draw six paths: (1) sign up with email+password, (2) sign in with email+password, (3) sign in with GitHub OAuth, (4) magic link, (5) org invitation acceptance, (6) accessing a protected route. For each path, label every token generated, every session created, every redirect, and every security boundary. Ask yourself: where could a token be intercepted? Where could a session ID be guessed or stolen? Where does a missing org_id filter cause a data leak between tenants? Which paths depend on email delivery — and what happens if an email is delayed? Keep this diagram. Every subsequent lesson implements one piece of it, and the final lesson audits the complete system against it.