Why I Rebuilt Auth Three Times in Six Months

Founder's Journey·December 8, 2024·7 min read

Session tokens, then JWTs, then back to sessions. Each migration taught me something about assumptions.

I've written three authentication systems for the same product.

Not because I'm indecisive. Because I kept solving the wrong problem.

Version 1: Sessions

The classic. Server-side sessions, Redis storage, HTTP-only cookies.

(defn create-session [user-id]
  (let [session-id (random-uuid)]
    (redis/setex (str "session:" session-id)
                 (* 7 24 60 60) ; 7 days
                 {:user-id user-id})
    session-id))

It worked. Users logged in. State persisted. Security was reasonable.

Then I added a second service.

The Problem with Sessions

Service A authenticates users. Service B handles billing. Both need to know who's logged in.

Options:

  1. Service B calls Service A for every request
  2. Both services share Redis access
  3. Service B trusts a signed token from Service A

Option 1: 50ms latency added to every billing operation. Option 2: Tight coupling. Redis becomes a single point of failure for both services. Option 3: JWTs.

Version 2: JWTs

Stateless authentication. Service A signs a token, Service B validates the signature. No shared state.

(defn create-jwt [user-id]
  (jwt/sign {:user-id user-id
             :exp (+ (now) (* 15 60))} ; 15 minutes
            signing-key))

Beautiful in theory. The token contains everything. No database lookup. No Redis. Pure cryptography.

I deployed it.

The Problem with JWTs

User reports: "I logged out but I'm still logged in on my other device."

JWTs can't be revoked. The token is valid until it expires. Log out on one device, the token on another device still works.

Solutions:

  1. Short expiry (15 minutes) + refresh tokens
  2. Token blacklist (defeats stateless benefit)
  3. Token versioning in the database

I implemented refresh tokens. Now I had two token types, rotation logic, and edge cases around refresh token theft.

Then a user's account got compromised.

I needed to revoke all their sessions immediately. With JWTs, I couldn't. The attacker had 15 minutes of access after we detected the breach.

Version 3: Sessions (Again)

Back to Redis. But smarter.

(defn create-session [user-id]
  (let [session-id (random-uuid)
        session {:user-id user-id
                 :created-at (now)
                 :device-info (get-device-info)}]
    ;; Index by user for bulk revocation
    (redis/sadd (str "user-sessions:" user-id) session-id)
    (redis/setex (str "session:" session-id)
                 (* 7 24 60 60)
                 session)
    session-id))

(defn revoke-all-sessions [user-id]
  (let [session-ids (redis/smembers (str "user-sessions:" user-id))]
    (doseq [sid session-ids]
      (redis/del (str "session:" sid)))
    (redis/del (str "user-sessions:" user-id))))

For cross-service auth, I added internal JWTs—short-lived (60 seconds), service-to-service only, never exposed to clients.

(defn internal-service-token [session]
  (jwt/sign {:user-id (:user-id session)
             :session-id (:id session)
             :exp (+ (now) 60)}
            internal-signing-key))

Service A validates the session, generates an internal token, passes it to Service B. Service B validates the signature, trusts the claims for 60 seconds max.

What I Learned

Each approach has trade-offs:

ApproachRevocationStatelessCross-Service
SessionsInstantNoHard
JWTsImpossible*YesEasy
HybridInstantPartiallyReasonable

*Without blacklists or short expiry + refresh tokens

The JWT hype cycle sold me on statelessness. But statelessness means giving up control. When a security incident happens, control matters more than elegance.

Why can't JWTs be revoked once issued?

The token is self-contained and valid until expiry — there's no server-side state to invalidate

Click to reveal answer

What's the hybrid auth approach for cross-service systems?

Client-facing sessions for instant revocation, short-lived internal JWTs for service-to-service trust

Click to reveal answer

The Real Lesson

I didn't need stateless auth. I needed cross-service auth.

Sessions with service-to-service tokens solve both problems. The client gets revocable sessions. Internal services get signed, verifiable claims.

Three rewrites taught me to question the premise. "JWTs for authentication" isn't wrong—it's incomplete. The question is: "Authentication for what scenario?"


Six months. Three systems. One insight.

Stateless is a feature, not a goal. Know what you're trading away before you trade it.