Building a Production-Style Auth System: What I Shipped and What I Learned
Over the past few weeks, I built a full authentication system from scratch. There was no auth-as-a-service or boilerplate template, just Node.js, Express, PostgreSQL with raw SQL, Redis, and BullMQ. The goal wasn’t just to get login working. I wanted to truly understand why production auth systems are designed the way they are. I also wanted something real to showcase during system design discussions in interviews.
Here’s what I shipped and what building it taught me.
What’s Live
-
Registration with email normalization, handling duplicate emails through Postgres’s unique-violation error code, and bcrypt for password hashing
-
Email verification with hashed, single-use, TTL-expiring tokens stored in Redis
-
Resend verification email with a reverse index to ensure resending correctly invalidates the previous token rather than leaving orphaned ones
-
Login with account lockout after repeated failed attempts, and a stored
rolefor role-based access control -
Logout tied to
authMiddleware, with an identity check between the access token and the refresh token cookie -
Refresh token rotation with theft detection using token "families" that get fully invalidated if an old, already-rotated token is replayed
-
Get profile cached in Redis to reduce repeated database hits for a high-traffic endpoint
-
Update profile with cache invalidation integrated into the same write path
-
Change password while logged in and forgot/reset password via an emailed token—both of which force-invalidate every active session across all devices
-
Rate limiting tiered by endpoint sensitivity, in addition to account lockout
That’s a complete account lifecycle—not just "sign up and log in," but all the dull, vital details that many tutorials overlook: session revocation, cache staleness, preventing abuse, and what happens with a hostile request.
The Pattern I Kept Reusing: Hash the Secret, Never Store It Raw
Every token in this system—email verification and password reset—follows the same format: generate a high-entropy random value, hash it with SHA-256, store only the hash as a Redis key, and give the raw value to the user via email link. Verification involves re-hashing the incoming value and checking if that exact key exists.
What I didn’t expect: this only works because tokens and passwords have different threat models. Passwords are low-entropy secrets that people choose, so they need bcrypt—intentionally slow, salted, and resistant to brute force. A 256-bit random token doesn’t need those protections; it must be fast and predictable because there's nothing to brute-force. I previously viewed "hashing" as a single concept. It’s not; the reason behind the algorithm choice is just as important as the choice itself.
The Bug Class That Kept Reappearing: TOCTOU
I encountered the same type of race condition three times in different scenarios:
-
Registration—two almost simultaneous sign-ups with the same email, both passing an existence check before either insert happens. This was fixed with Postgres’s
23505unique-violation code instead of a pre-check. -
Email verification—reading a token, checking it, then deleting it as two separate Redis calls, allowing a double-click to use the same token multiple times. This was fixed with an atomic
GETDEL. -
Login lockout—calculating
failed_attempts + 1in JavaScript and writing it back, which loses increments under concurrent failed logins. The fix was to let Postgres handle the increment atomically in theUPDATEitself.
None of these issues seemed related when I first encountered them. They only appear linked in hindsight—"read state, then act on it in a separate step" is a pattern, not a specific bug, and it can appear in any data store, not just one. This is likely the most transferable lesson from this project.
The Design Gap I Didn't See Coming: Wrong-Direction Data
My Redis key for email verification was verify:{hashedToken} → userId. This worked well for checking if a token belonged to a user, which is exactly what verification needs. However, when I created resend verification, I immediately ran into a problem: I had a userId, but no way to retrieve the token I needed to delete. Hashes can't be reversed, and the raw token was already emailed and gone.
The solution was a reverse index—a second key mapping user → current token hash, written alongside the original. The lesson extended further than I anticipated. I faced the exact same issue again later with refresh token families, needing a userId → all their active sessions index to support "log out everywhere" on a password change. Once I recognized the pattern, I added the second index upfront instead of trying to work around it.
Takeaway: design your storage direction around every access pattern you’ll need, not just the first one you consider. If "the other direction" isn’t clear yet, it usually appears in the next feature.
Small Bugs That Taught Big Lessons
Several of the most revealing bugs weren’t architectural; they were minor typos with significant consequences:
-
samesitevssameSitein a cookie options object. JavaScript object keys are case-sensitive, so mismatched keys fail silently; there’s no error or warning, and the cookie simply loses that attribute. I discovered this because I looked for it after another issue arose. -
Argument order in a shared response class. I had
ApiResponse(statusCode, message, data)as the actual signature but was calling it as(statusCode, data, message). This meant user objects landed in themessagefield while strings landed indata. It functioned enough to not appear broken at a glance, which made it particularly dangerous. -
Forgetting
JSON.parse()when reading from Redis cache. Redis only stores strings, so anything that isn’t explicitly parsed stays a string, including nested JSON, which then returns as an ugly escaped mess if you don’t catch it. -
A
windowMsunit conversion bug in a rate limiter. I wrote15 * 60 * 60 * 1000intending to create a 15-minute limit, but that calculation actually turned out to be 15 hours—just one extra* 60transformed a login rate limit into something that would lock out a mistyped-password user for almost a day. I caught this by doing the arithmetic by hand instead of trusting that the code "looked" right.
None of these required deep knowledge of systems to fix. They demanded the habit of checking the raw response or raw math at the ground level instead of merely relying on the code's appearance. I feel like I developed this debugging instinct more than any single feature.
Rate Limiting: Layering, Not Replacing, Account Lockout
Once the core functions worked, I added express-rate-limit across the auth routes, tiered by how sensitive each endpoint is: a strict limiter on registration and email-triggering actions (resending verification, password reset—anything that involves sending real emails or creating actual accounts), a tighter limiter for login attempts, a looser one for general reads, and a dedicated one for profile updates.
What’s important to understand isn’t just the use of the library but why this doesn’t replace the account lockout I built earlier. They protect against different types of attacks:
-
Account lockout (per-account, tracked in Postgres) prevents someone from brute-forcing one specific account, even if they spread their attempts over many IPs or proxies.
-
Rate limiting (per-IP, tracked in memory/Redis via the limiter) prevents someone from bombarding many different accounts from one source or spamming account creation/email-sending actions that use real resources.
Neither one covers the other's weaknesses alone. This follows the same "layered defense" concept as having both a network firewall and an application-level auth check—each layer assumes the other might fail.
The Decision I'm Most Confident About: Raw SQL, No ORM
I chose pg with hand-written SQL instead of using an ORM early on, mainly to force myself to grasp what my queries were actually doing. This decision paid off in a way I didn’t fully expect. Every atomicity fix in this project—the unique-violation catch, the atomic UPDATE ... SET x = x + 1, the RETURNING clauses that allowed me to verify a write affected a row—was something I could reach for because I could see the SQL directly, not because an ORM abstracted it. I’m not sure I would have identified the lockout race condition as swiftly if the increment were hidden behind user.failedAttempts++.,
What’s Still Missing
This project isn’t complete. I have deliberately left some things for later, including RBAC middleware that reads the role I’ve been embedding in access tokens, and an automated Jest/Supertest suite to replace manual Postman testing.
The Actual Takeaway
Going in, I thought this project was about learning JWTs and bcrypt. It turned out to be about something else entirely: most of the hard parts of auth aren't the cryptography — they're the state management. Where does a piece of data live, which direction do you need to look it up from, what happens when two requests touch it at once, what goes stale when something else changes, and what happens when someone tries to abuse the system on purpose. Those are the questions that don't show up in a tutorial's happy path, and they're the ones that actually separate "it works on my machine" from "it works."