#149: Why Authentication Is the Feature Most Developers Finish Without Finishing
And how the login form is the easy part
There is a particular kind of confidence that comes from watching your own login form work. You type an email and a password, the server answers with a token, the dashboard appears, and it feels like the authentication chapter of your project is closed. For most intermediate developers, that moment arrives fairly early, and it is quietly misleading.
The login endpoint is the most visible part of an authentication system, but it is also the simplest. What separates a demo from a production system is everything that happens after the user is logged in: how their session survives, how it ends, how it gets stolen, and how your application behaves when several of those things happen at once.
If you have ever shipped an app where users mysteriously get logged out, where a stolen token stays valid forever, or where a page refresh dumps someone back to the login screen, this article is about the missing pieces. And if your framework manages sessions for you, whether that is Rails, Django, or Laravel, everything below is what that machinery is quietly doing on your behalf, so it is worth seeing it in daylight at least once.
Before we get into all that, here’s a TL;DR of the links you must check out:
🕵️ Cloudflare’s Precursor Knows You’re a Bot by How You Move
🔁 Loop Engineering: Stop Prompting, Start Designing the Loop
⚡Playwright on GitHub Actions: The Setup That Actually Runs Fast
💸 The Same TypeScript Costs 73% More Tokens on Claude Than GPT
Build production-grade apps with Claude Code
Claude Code can generate code fast, but this workshop shows developers how to make it work reliably inside a real codebase.
On July 31, Luís Rodrigues and Gabriela de Queiroz will build live and show how to use
CLAUDE.md, Skills, MCP workflows, scaffolds, and guardrails to create production-grade apps. You’ll follow the setup step by step and leave with patterns you can apply to your own projects.
Two questions, two status codes
Authentication answers the question of who you are, while authorization answers the question of what you are allowed to do, and they are sequential by nature, because you cannot decide what someone may do before you know who they are. The classic analogy is a building with a keycard system: showing your ID at the entrance is authentication, and whether your card opens the door to the seventh floor is authorization.
This distinction maps directly onto two status codes that developers routinely misuse.
401 means the server does not know who is asking, because the token is missing, malformed, or expired. The correct client reaction is a silent token refresh followed by a retry.
403 means the server knows exactly who is asking and is refusing anyway. The correct client reaction is a permissions message, with no retry.
The naming is genuinely unhelpful, since 401 is labeled “Unauthorized” when it really means “unauthenticated,” but the convention is universal. When your API is consistent about the difference, your frontend can react intelligently, and when it is not, the client is forced to guess. Guessing in an auth flow is how you end up with refresh loops and phantom logouts.
Statelessness is a trade, not a gift
JSON Web Tokens are popular for a good reason. Because a JWT carries its own claims and a cryptographic signature, any server holding the secret can verify it without touching a database or a shared session store, whether you run one server or fifty behind a load balancer. That property is what people mean when they call JWTs stateless, and it is a genuine architectural advantage.
The part that gets skipped in most tutorials is what statelessness costs you. A signature does not stop being valid because you wish it would, so if a long-lived token is stolen, there is no server-side session to destroy, and the attacker holds a key that works until it expires. A related misunderstanding causes its own incidents: the payload of a JWT is Base64 encoded, not encrypted, so anyone holding a token can read every claim inside it. The signature protects against tampering, never against reading.
The mature answer to the revocation problem is to stop pretending one token can do two jobs.
The database record is the whole point of the second token. By persisting refresh tokens server-side, you reintroduce just enough state to make revocation possible: logging out deletes the record, and “log out of all devices” deletes every record for that user. You trade a little purity for the ability to actually end a session.
Rotation completes the design. Each refresh deletes the old token and issues a new one inside a transaction, so every refresh token works exactly once. If an attacker replays a token the legitimate user already spent, the lookup fails, and a careful implementation treats that as a breach signal and revokes everything for that account. A stolen credential becomes a tripwire instead of a skeleton key.
Where a token lives matters more than how it is signed
Ask an intermediate developer where their tokens are stored and the honest answer is often localStorage, because that is what half the internet’s tutorials demonstrate. It is also the one answer that security reviewers will flag immediately, since anything in localStorage, sessionStorage, or IndexedDB is readable by any script on your page, and a single cross-site scripting vulnerability hands an attacker every token you have saved.
There are three defensible strategies, and choosing between them is a real architectural decision rather than a default.
The hybrid approach is the common sweet spot. Because API requests carry the access token in an Authorization header rather than a cookie, cross-site request forgery is a non-issue for normal calls, and because the refresh cookie is httpOnly, injected scripts cannot read it. Its main cost is that the in-memory token vanishes on page refresh, so the app must silently call the refresh endpoint on load.
The cookie-only strategy trades that residual XSS exposure for CSRF risk, which is manageable but adds infrastructure you must maintain, while the backend-for-frontend pattern keeps tokens out of the browser entirely and suits genuinely sensitive data. The right choice depends on what you are protecting. What is not defensible is making no choice at all and letting a tutorial make it for you.
When your security features attack each other
Here is a failure mode that almost nobody designs for on the first attempt. A user leaves your app open in a tab and comes back twenty minutes later, after the access token has expired. They click something that fires three API calls at once, and the sequence unfolds like this:
All three requests receive a 401, and all three independently try to refresh.
The first refresh succeeds and, because of rotation, consumes the refresh token.
The second and third arrive holding a token that no longer exists.
Reuse detection correctly interprets that as a possible attack and revokes every session the user has.
Your two security features, each individually correct, have combined to log out a completely legitimate user.
The fix is small and elegant. The client keeps a single shared reference to any refresh request currently in flight, and every caller that hits a 401 during that window awaits the same promise instead of starting its own, so one network call happens and the queued requests retry with the new token. One guard remains: a 401 from the refresh endpoint itself must never trigger another refresh, or the client deadlocks.
The broader lesson is worth more than the pattern. Authentication is a system, and systems fail at the seams between correct components: rotation is right, reuse detection is right, and concurrent requests are unavoidable, yet together they produce a bug that only appears under real usage. Reasoning about interactions, not just features, is what the intermediate-to-senior transition largely consists of.
The quieter layers
A few remaining decisions round out a production-grade setup, and each one exists to contain the failure of another:
Hash passwords with a memory-hard algorithm such as Argon2id rather than a merely slow one, because modern GPUs shrug at computational cost but choke on memory cost.
Rate limit the login, registration, and refresh endpoints, and run the limiter before body parsing so abusive traffic is rejected before the server spends resources on it.
Cap request body sizes and set cookie flags properly, meaning secure and sameSite in production, so no single oversight becomes an open door.
Serve everything over HTTPS, because every guarantee above assumes the transport itself is not readable.
The organizing principle is that no single failure should compromise the whole system.
Takeaways
Treat 401 and 403 as different events with different client responses: one triggers a silent refresh and retry, the other shows a permissions error and stops.
Use two tokens with different lifetimes, and persist refresh tokens server-side so sessions can actually be revoked.
Never store tokens in localStorage. Choose deliberately between the hybrid, cookie-only, and backend-for-frontend strategies based on how sensitive your data is.
Rotate refresh tokens so each works once, and treat reuse of a consumed token as a breach signal.
Deduplicate concurrent refresh attempts behind a single in-flight promise, or rotation and reuse detection will log out your own users.
Remember that a JWT payload is readable by anyone holding the token, so it should never contain anything you would not print publicly.
None of this argues against eventually using a managed provider like Auth0 or Clerk, which are sensible choices for many teams. It argues for understanding the machine before you outsource it, so you can evaluate a provider’s decisions instead of inheriting them blindly. The login form was never the hard part. The lifecycle is, and now it is yours.
This article is adapted from the book Full-Stack React, TypeScript, and Node (Second Edition). You can order it there to read more.
This Week in the News
🕵️ Cloudflare’s Precursor Knows You’re a Bot by How You Move: Cloudflare’s new behavioral validation engine watches the entire session, not just a single checkpoint, turning how humans and bots actually interact into continuous detection signals. With AI agents now browsing indistinguishably from people, this feels like the quiet obituary for the CAPTCHA: the future of bot detection is behavioral, invisible, and always on. If you build agents or defend against them, this is required reading.
🎨 Paper Shaders Is Now Free and Fully Open Source: Zero-dependency canvas shaders for React and vanilla JS, and now completely free. Beautiful, GPU-driven backgrounds and effects usually mean pulling in Three.js and a weekend of GLSL headaches; this gives you the eye candy with none of the dependency weight. One of those rare libraries where the demo page alone will sell you.
🖼️ Takumi 2.0: JSX to Images, No Browser Required: Takumi renders JSX and HTML straight to images using a single Rust binary, and this major release squares up directly against Satori and next/og for JSX-based social card rendering. Skipping the headless browser entirely means faster, cheaper OG image generation with fewer moving parts. If your social cards are currently rendered by a Chromium instance you resent paying for, take a look.
Beyond the Headlines
🔁 Loop Engineering: Stop Prompting, Start Designing the Loop: Addy Osmani argues that prompting skills are becoming obsolete; the real skill is designing the system that prompts your agents for you. He breaks it into five building blocks (automations, worktrees, skills, connectors, sub-agents) plus persistent memory, and shows how both Claude Code and the Codex app now ship all of them natively. Refreshingly, he stays skeptical about token costs, which is exactly the caveat this trend needs.
💸 The Same TypeScript Costs 73% More Tokens on Claude Than GPT: A genuinely eye-opening measurement study: because every vendor tokenizes differently, the price per million tokens on the rate card tells you almost nothing about your actual bill. The same TypeScript file becomes 1,178 tokens on Anthropic’s newest tokenizer versus 681 on GPT’s o200k, and Anthropic’s tokenizer change added roughly 30% more tokens at the same list price. If you’re comparing model costs by pricing page alone, you’re doing it wrong.
⚡ Playwright on GitHub Actions: The Setup That Actually Runs Fast: Slow Playwright CI is almost never your test suite’s fault. It’s the default workflow re-downloading 300 to 500 MB of browsers on every push and running tests on a single worker. This walkthrough fixes both with caching and parallelism, gets a real pipeline well under five minutes, and, crucially, tells you when sharding is actually worth it (later than you think).
🔨 Good Tools Are Invisible: The creator of Odin takes aim at a habit we all recognize: dressing up a tool’s shortcomings as a fun puzzle and then evangelizing the puzzle. His test is brutal and correct: feeling clever is not the same as being productive, and the honest metric is wall-clock time. Guaranteed to mildly offend at least one vim devotee on your team, which is precisely why you should send it to them.
🧵 Node.js Worker Threads in Production: Spawning a worker thread is one line of code; making it production infrastructure is everything else. Inngest documents the unglamorous reality: designing the postMessage protocol as a real API boundary, proxying callbacks, shipping the worker as a build artifact, capping crash-respawn loops, and treating shutdown as a drain protocol. This is the article the official docs should link to.
🍦Why Vanilla JS: A contrarian defense of building for the web without frameworks, arguing the problems frameworks solve can be handled with plain JavaScript and a little discipline. Whether you nod along or grind your teeth, it’s a useful provocation: the strongest counterargument (frameworks are really about teams agreeing on solutions, not solving problems) came from the ensuing Hacker News debate, and both sides are worth your time.
Developer Toolbox
🧠 LiteRT.js: Google’s High-Performance Web AI Inference: Google brings its native on-device inference runtime to the browser, letting you run .tflite models locally with WebAssembly, GPU acceleration via ML Drift, and WebNN support on the way. It’s positioned as the serious evolution of TensorFlow.js, swapping JavaScript kernels for the real optimized runtime. Local inference means zero server costs, better privacy, and latency your users will actually notice.
🧹 FracturedJson: JSON Formatting Humans Can Actually Read: A JSON formatter that hits the sweet spot between minified chaos and pretty-printed sprawl: human-readable but fairly compact. If you regularly stare at big lumps of JSON, this makes them genuinely pleasant to scan, and it even tolerates non-standard JSON comments. A small tool that removes a daily annoyance, which is the best kind.
That’s all for this week. Have any ideas you want to see in the next article? Hit Reply!
Cheers!
Editor-in-chief,
Kinnari Chohan
👋 Advertise with us
Interested in sponsoring this newsletter and reaching a highly engaged audience of tech professionals? Simply reply to this email, and our team will get in touch with the next steps.




