Skip to content
System design course
Ch.4 · Designing real systems·how to build it ·7 min read

Building an authentication service

Implement secure registration and login, JWT issuance and local verification, refresh-token rotation with revocation, and defenses against common attacks.


Registration and login

def register(email, password):
    if users.exists(email): raise EmailTaken()
    salt = secrets.token_bytes(16)
    users.insert(email=email, salt=salt,
                 password_hash=argon2(password, salt))   # slow, salted hash

def login(email, password):
    user = users.get(email)
    if not user or not argon2_verify(password, user.salt, user.password_hash):
        rate_limiter.record_fail(email)        # throttle credential stuffing
        raise InvalidCredentials()             # same error for both cases (no enumeration)
    access  = issue_jwt(user.id, ttl="15m")
    refresh = issue_refresh(user.id)           # opaque, stored server-side
    return access, refresh

Note: return the same error whether the email or the password is wrong, so an attacker can’t enumerate accounts.

JWT issuance and verification

Sign access tokens with a private key; services verify with the public key locally — no auth-service call per request:

def issue_jwt(user_id, ttl):
    payload = {"sub": user_id, "exp": now()+ttl, "iat": now(), "roles": roles(user_id)}
    return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256")

# in any service / the gateway:
def verify(token):
    claims = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])  # checks sig + exp
    return claims["sub"]                       # trusted user id, no DB hit

Keep tokens short-lived so a stolen one expires fast, and rotate signing keys (publish keys via a JWKS endpoint).

Refresh tokens with rotation + revocation

Refresh tokens are long-lived, so they must be revocable and rotated to detect theft:

def refresh(refresh_token):
    rec = refresh_store.get(hash(refresh_token))     # store hashes, not raw tokens
    if not rec or rec.revoked or rec.expired():
        raise InvalidRefresh()
    refresh_store.revoke(rec.id)                     # one-time use (rotation)
    new_refresh = issue_refresh(rec.user_id)
    if rec.already_used:                             # reuse → token was stolen
        revoke_all_sessions(rec.user_id)            # nuke the family, force re-login
    return issue_jwt(rec.user_id, "15m"), new_refresh

Rotation (each refresh invalidates the last) means a stolen-then-reused refresh token is detected — the legit user’s next refresh fails the reuse check and you can revoke the whole session family.

Logout and revocation

  • Logout → delete/revoke the refresh token server-side; the access token expires on its own within minutes.
  • Global logout / “log out everywhere” → revoke all refresh tokens for the user; optionally maintain a small token blocklist (by jti) for immediate access-token invalidation when truly needed.

Storage and scale

  • Users in a durable, sharded SQL store (by user id); email uniquely indexed.
  • Refresh tokens and the blocklist in Redis (fast, TTL’d).
  • The verification path is stateless (local JWT check), so app/API tiers scale horizontally; only login/refresh touch the stores.

Security hardening (mention these)

  • Cookies: refresh token in an httpOnly, Secure, SameSite cookie (not JS- readable) to blunt XSS token theft; CSRF tokens for cookie-based auth.
  • Brute force: per-account + per-IP rate limiting, lockout/CAPTCHA after N fails.
  • MFA / TOTP: verify a second factor before issuing tokens.
  • Transport: everything over TLS; never log tokens or passwords.
  • Password reset: single-use, expiring, signed link emailed to the user.

The takeaway

Concrete signals: Argon2/bcrypt salted hashing, RS256 JWTs verified locally by services, rotating + revocable refresh tokens (with reuse detection), httpOnly cookies, rate-limited login, and non-enumerable errors. The access/refresh split is the crux — stateless speed for the hot path, a revocation point for safety — and every later system assumes this identity layer underneath.