DEV Community

Cover image for Yet Another Password Authentication Flow.. but hear me out
Iulian Iordache
Iulian Iordache

Posted on

Yet Another Password Authentication Flow.. but hear me out

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 be

  • Proof
    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)
}
Enter fullscreen mode Exit fullscreen mode

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
  )
}
Enter fullscreen mode Exit fullscreen mode

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)
)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode
// 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) })
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Logout

POST /auth/logout

- Bump session.version
- Revoke all user's refresh tokens
- Clear cookie
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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 an httpOnly cookie and enable credentials so only the browser can access and send it with the requests.
  2. 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.
  3. 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.
  4. Missing rate limits: Welcome to the credential stuffing festival everybody
  5. 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)