Auth is one of those topics everyone touches but few explore deeply. Most posts repeat the same template: hash passwords, issue a JWT, add middleware, done. That works for demos, not production. MERN stack buddies will learn a lot from this one!
This is a blueprint, not a tutorial. It shows you the architecture that Auth0, Clerk, and Supabase use internally for password authentication. You'll see the patterns, understand the threat models, and know how to adapt them to your stack.
I tried to provide a "more complete" flow than what I've seen out there on YouTube where they only focus on the token generation but they don't talk about refresh tokens, how to SECURELY store them, how to retrieve them, how to detect replays and how to handle revocation and server-side session versions.
The Three Pillars: Identity, Proof, Trust
Every auth system, from basic logins to OAuth, is built on these:
Identity
Who you claim to beProof
Evidence that proves your claim (password, key, code)Trust
How the system remembers you proved yourself (sessions, tokens)
The rest is details.
Passwords: Store Proof, not Plaintext
A password exists to provide proof once, then never again until the next login. Your job is to make reversing that proof computationally infeasible.
Use Argon2id with memory-hard parameters. The numbers below are reasonable defaults, not universal law, because hardware and concurrency vary across deployments.
// Parameters tuned for modern hardware (adjust for your environment ofc)
hashPassword(password) {
return argon2.hash(password, {
type: argon2id,
memoryCost: 65536, // 64 MiB (many prod systems use 32–64 MiB)
timeCost: 3, // ~50–150ms depending on your server
parallelism: 4, // matches typical CPU cores but tune for load
hashLength: 32 // 256 bits is the industry standard
})
}
verifyPassword(hash, input) {
return argon2.verify(hash, input)
}
Why these numbers you might ask, well:
-
memoryCost: 65536 forces 64 MiB RAM per hash. Attackers can't parallelize millions of attempts on limited GPU memory. -
timeCost: 3 makes each hash take ~50-150ms. Negligible for one login, crushing for brute force. -
parallelism: 4 uses your CPU cores efficiently without starving other requests, if you're in an environment where CPU time is limited like serverless, consider using a lower number, decide what's best for your use case.
The threat model: Always assume your passwords will leak someday. These parameters make brute forcing painful, unless the user chooses something like "123456789", which, yeah, will be cracked instantly.
Access Tokens: Short-Lived Signed Envelopes
Access tokens are ephemeral proof you were authenticated recently, think of them as signed receipts, not universal truth.
createAccessToken(userId, sessionVersion) {
return jwt.sign(
{
sub: userId,
ver: sessionVersion // ties token to the server state
},
ACCESS_TOKEN_SECRET,
{ expiresIn: "10m" } // 5–15 minutes is typical
)
}
Key principle: sessionVersion lives server-side. Bump it and all access tokens go stale instantly. This sidesteps the “JWTs can’t be revoked” myth.
Lifetime: Very short lived, 5-15 minutes max. Long enough to not annoy users, short enough that a stolen token has limited value.
Refresh Tokens: Long-Lived Database-Backed Trust
Refresh tokens are the "remember me" mechanism. They live in your database, rotate on every use, and track lineage for replay detection.
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
token_hash TEXT NOT NULL, -- SHA256 hash for indexed lookup
token_encrypted TEXT NOT NULL, -- AES-256-GCM encrypted blob
iv TEXT NOT NULL, -- AES-GCM IV
auth_tag TEXT NOT NULL, -- AES-GCM authentication tag
parent_id UUID, -- tracks rotation lineage
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT now(),
used_at TIMESTAMP,
revoked_at TIMESTAMP,
INDEX(token_hash),
INDEX(user_id, revoked_at)
)
Now some of you might ask, "Why both hash AND encryption?", hear me out:
- Hash: Fast O(1) (again O(log n) in databases) lookup without decrypting every token
- Encryption: If DB dumps, attacker still needs your encryption key
- AES-GCM note : GCM requires an IV and auth tag; those need to be stored.
// generate once with a CSPRNG library (node's "crypto" module in this case)
ENCRYPTION_KEY = env.REFRESH_TOKEN_ENCRYPTION_KEY
createRefreshToken(userId, parentId = null) {
// generate cryptographically random token and again,
// use your platform's recommended way of generating random keys,
// "crypto/rand" in Go or "secrets" in Python
token = crypto.randomBytes(32).toString('hex')
hash = sha256(token)
// now encrypt the token for storage, we'll used AES for this
{ encrypted, iv, authTag } = aes256gcm.encrypt(token, ENCRYPTION_KEY)
expiresAt = now() + 30.days
db.insert({
id: uuid(),
user_id: userId,
token_hash: hash,
token_encrypted: encrypted,
iv,
auth_tag: authTag,
parent_id: parentId,
expires_at: expiresAt
})
return token // send the plaintext to the client
}
Threat model for this one:
- If the attackers dump your DB, what will they get is a bunch of encrypted tokens, pretty much useless without keys
- Now if they steal a single token (let's assume they are limited to a 30-day window) you will easily detect these on replay
"But grandpa, what happens if the attackers get both the DB and the encryption keys?", well, at this point, tbh, you have bigger issues but the session version bump still invalidates everything :)
Token Rotation + Replay Detection
Every time a refresh token is used, it's marked as used and replaced, if a token is used twice, you know one request was from an attacker.
rotateRefreshToken(oldTokenId) {
// mark as used
db.update(oldTokenId, { used_at: now() })
// create child token
return createRefreshToken(
oldToken.user_id,
parentId: oldTokenId
)
}
detectReplay(token) {
if (token.used_at != null) {
// this token was already used, possible attack
// revoke entire session tree
bumpSessionVersion(token.user_id)
// up to you what you do now: lock account, alert security team
return REPLAY_DETECTED
}
return OK
}
Parent-child lineage: If token_A has two children (token_B and token_C), one was created by an attacker. The legitimate client will send token_B, the attacker token_C, so, whichever arrives second triggers replay detection, easy.
Session Versions: Server-Side Revocation
JWTs are stateless, but you still need instant revocation. Session versions solve this.
CREATE TABLE sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
version INT NOT NULL DEFAULT 1,
last_active TIMESTAMP
)
// on login
session = db.findOrCreate(user_id)
accessToken = createAccessToken(user_id, session.version)
// pn protected request
validateAccessToken(token) {
payload = jwt.verify(token)
session = db.find(payload.ver ? payload.sub : null)
if (session.version != payload.ver) {
return INVALID // token was issued before version bump
}
return VALID
}
// on logout, password change, or security event
revokeAllSessions(userId) {
db.updateWhere({ user_id: userId }, { version: increment(1) })
}
This works beautifully because: Incrementing the version invalidates all access tokens and refresh tokens tied to that session, no need to track individual tokens.
Bump the version on logout, password change, or anything security-sensitive.
The Complete Flow
Login
POST /auth/login
{
email: "user@example.com",
password: "..."
}
- Verify password with argon2
- Find or create session for user
- Create access token with session.version
- Create refresh token (store encrypted)
- Set httpOnly cookie with refresh token
- Return { accessToken }
Refresh
POST /auth/refresh
Cookie: refresh_token=...
- Look up token by hash (O(1))
- Decrypt and validate
- Check expiration, revocation
- Detect replay (fail if used_at exists)
- Rotate token (mark old as used, create new)
- Fetch current session.version
- Create new access token
- Set new refresh token cookie
- Return { accessToken }
Protected Request
GET /api/resource
Authorization: Bearer <access_token>
- Verify JWT signature
- Check expiration
- Fetch session by payload.sub
- Validate payload.ver == session.version
- Allow request
Logout
POST /auth/logout
- Bump session.version
- Revoke all user's refresh tokens
- Clear cookie
Password Reset
POST /auth/reset-password
{
token: "...",
newPassword: "..."
}
- Validate reset token
- Hash new password
- Update user.password_hash
- Bump session.version (invalidates all devices)
- Revoke all refresh tokens
- Force re-login everywhere
Critical Security Considerations
httpOnly Cookies for Refresh Tokens
setCookie(res, "refresh_token", token, {
httpOnly: true, // JS can't touch it
secure: true, // HTTPS only
sameSite: "strict", // CSRF protection
path: "/auth/refresh", // scoped, though path scoping is weaker than domain/sameSite
maxAge: 30 * 86400 // in your code, don't use these magic numbers, create a variable somewhere where you comment what these numbers are please, ok?
})
Note: path helps reduce accidental exposure, but the real safety comes from httpOnly, secure, sameSite, and domain scoping.
Token Expiration Hierarchy
- Access token: 5-15 minutes (minimizes damage from XSS)
- Refresh token: 7-30 days (balance between security and UX)
- Session version: Never expires (but bumped on security events)
When to Bump Session Version
- User logs out
- Password changed
- Email changed
- MFA enabled/disabled
- Suspicious activity detected
- User clicks "log out all devices"
Rate Limiting
// per IP
POST /auth/login → 5 attempts / 15 minutes
POST /auth/refresh → 100 attempts / hour
// per user
POST /auth/login → 10 failed attempts → lock account for 1 hour // this example is very strict and you should use whatever makes sense for your use case
Now the fun part: Scaling Considerations
We talked about hash and encryption at the beginning of the article, that little decision helps us scale real production systems;
- Using a hash for lookup: WHERE token_hash = sha256(input) is O(1) ( O(log n) in databases :) )
- You still need to decrypt but you only do that for the mached record
- Index aggressively, (user_id, revoked_at), (expires_at)
Cleanup Strategy
// run daily
DELETE FROM refresh_tokens
WHERE expires_at < now() - INTERVAL '7 days'
// or on refresh attempt
if (token.expires_at < now()) {
asyncCleanup(token.user_id)
return EXPIRED
}
Also check expiration during refresh and soft-retire old token trees.
If you got here, you might still have some questions, I'll try to guess one of them; "Wait what? What about multi device sessions? The architecture above uses one session version per user!", well my friend, if you do something like this:
CREATE TABLE sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
device_id TEXT, -- browser fingerprint or explicit device ID for example
version INT DEFAULT 1,
last_active TIMESTAMP
)
Refresh tokens then reference session_id instead of user_id. You can revoke individual devices without logging out everywhere.
Common Pitfalls
I'll go in order of importance:
-
Storing the refresh token in
localStorage: From what I've seen in YouTube tutorials, this is often labeled as "you shouldn't do this but I do it for the sake of the tutorial", where in reality it's very simple, use anhttpOnlycookie and enablecredentialsso only the browser can access and send it with the requests. - No rotating refresh tokens or no refresh tokens at all: The stolen token will be valid until the end of time in most JWT implementations I've seen or until natural expiration.
- Weak secrets: Please use a CSPRNG library, node has one, golang has one, python has one, don't try to come up with strings yourself.
- Missing rate limits: Welcome to the credential stuffing festival everybody
- This one is on 5 but should actually be up there on 1. No replay detection: Well.. attacker can reuse stolen refresh token indefinitely
When to Use This vs. a Service You Might Ask Next
I'd say, build this yourself when:
- You need full control over auth flows
- You have custom business logic tied to authentication
- Avoiding vendor lock-in is critical
- You're operating in a regulated industry with strict compliance requirements, and since you have full control, it makes it easier to tune the flow
Use Auth0/Clerk/Supabase when:
- You need OAuth/social logins (Google, GitHub, etc.), many libraries have a truck load of integrations for every last social login, so implementing them yourself is a waste of time, using Auth0 for password only defeats the purpose of a service
- You want MFA/WebAuthn out of the box
- You're building an MVP and speed matters but as your app scales so does the consequences of your decision... am talking about pricing here
- You have a small team without dedicated security expertise although with a bit of care, the service becomes redundant
This blueprint is the foundation. OAuth, MFA, and passwordless flows layer on top of these same primitives: proof, trust, and server-side validation.
Sooo
Summary
Passwords: Argon2id with memory-hard parameters
Access tokens: Short-lived JWTs tied to session versions
Refresh tokens: Long-lived, encrypted at rest, rotated on use, parent-child tracking
Session versions: Server-side revocation mechanism
Replay detection: Identifies stolen tokens via usage patterns
This is what production auth looks like and this is the "hear me out part", IMO this is a more complete and production ready flow than what I've seen out there. Each piece has a job, and together, they create a system that's both secure and maintainable.
The goal was to have this as a blueprint so you can hand this to any engineer in any stack and they'll know what to build.
The code patterns shown here are language-agnostic, adapt them to your stack, but keep the architecture intact.
Top comments (0)