DEV Community

Cover image for Designing JWT Auth the Right Way
Pallavi Kumari
Pallavi Kumari

Posted on

Designing JWT Auth the Right Way

You log in to an app.

Close the tab. Come back tomorrow.

You're still logged in.
Convenient? Yes.

Also the exact place where most JWT implementations quietly go wrong.

HTTP forgets everything between requests. Every call is a clean slate.

So something, somewhere, is remembering you.

That "something" is your auth system.

And JWT-based auth isn't just a technique — it's a series of deliberate trade-offs. Speed vs control. Statelessness vs revocability. Simplicity vs security edge cases. Nobody explains this part. This is the mental model I wish I had from day one.


Before we start

  • What is JWT ?
    • JWT (JSON Web Token) is an open standard for securely transferring information between two parties as a compact, signed JSON object. The server issues it, the client stores it, and sends it with every request. The server verifies it without ever checking a database — that is what makes it stateless. It is digitally signed using a shared secret via HMAC, or a key pair via RSA or ECDSA. JWTs are fast and scalable, but you cannot revoke a stolen token before it expires. That is the core tradeoff.
  • What is bcrypt ?
    • bcrypt is a password hashing function. Unlike regular hashing, it is intentionally slow, which makes brute-force attacks expensive. It also automatically generates and embeds a salt into every hash, so two identical passwords never produce the same hash.
  • What is an access token ?
    • A short-lived JWT the client sends with every request to prove identity. The server validates it by recomputing the signature. No database lookup needed. Expires in 15 minutes.
  • What is a refresh token ?
    • A long-lived token used only to get a new access token when the old one expires. It is stored server-side as a hash and sent to the client as an httpOnly cookie. This is where actual session control lives.

What JWT Actually Is

A JWT has three parts:

  • Header
  • Payload
  • Signature

The server creates it and signs it.

The client stores it.

And on every request, the client sends it back.

The server verifies the signature to ensure:

"This data hasn't been tampered with."

That's all a JWT really does.

JWT is not a session system. Treating it like one is where everything breaks.

Structure*:* header.payload.signature (xxxxx.yyyyy.zzzzz)

  • Header: JOSE Header (JSON Object Signing and Encryption):
  {
    "alg": "HS256", // signing algorithm, e.g. HMAC SHA256 or RSA
    "typ": "JWT"    // type of token
  }
Enter fullscreen mode Exit fullscreen mode
  • Payload: contains claims, key-value statements about an entity
  {
    "sub": "12345",       // registered  subject, typically a user id
    "iss": "auth.xyz",    // registered  issuer of the token
    "role": "admin",      // public  shared across systems
    "feature_flag": true  // private  specific to your application
  }
Enter fullscreen mode Exit fullscreen mode
  • Signature: verifies the payload was not modified in transit
  HMACSHA256(
    base64UrlEncoded(header) + "." + base64UrlEncoded(payload),
    secret
  )
Enter fullscreen mode Exit fullscreen mode

Claim Types:

  • Registered: Predefined and recommended. 3-char names to keep tokens compact.
  • Public: Custom claims shared across systems. e.g. role, permissions
  • Private: App-specific. Not registered or shared. Only meaningful within your own system. e.g. feature_flag

If signed with a private key, the sender's identity can be verified too, not just the payload integrity.


The Flow

A typical JWT flow looks like this:

  1. User logs in
  2. Server verifies credentials
  3. Server generates a token
  4. Client stores the token
  5. Client sends it with every request
  6. Server validates and responds

Simple.

But what you've actually done is given the client a self-contained proof of identity that works until it expires.

No server memory. No built-in revocation.

That's the trade-off.


Sign-up

  • POST /auth/sign-up with email and password.
  • Server hashes the password with bcrypt. The salt is auto-embedded into the hash, so it never needs to be stored separately.
  • User record and password hash are saved to the database. Plain-text password never touches storage.

Login

  • POST /auth/login with email and password.
  • bcrypt.compare(password, stored_hash) extracts the embedded salt, re-hashes the input, and compares.
  • On match, server signs the encoded header and payload with a secret to generate the JWT access token.
  • A refresh token is minted too. Plain value goes into an httpOnly cookie. Its hash is stored in the database. Access token goes in the response body, stored in client memory.
  • Access token expires in 15 min. Refresh token lasts 7 days.

On every request

  • Access token is sent in the Authorization: Bearer <token> header with every request.
  • Server splits the token into header, payload, and signature.
  • Recomputes the signature using the secret, header, and payload. A match means the payload is untampered and the request is valid.
  • The exp claim is also validated on every request.

Token refresh

  • Access token expired? Client sends POST /auth/refresh.
  • Server reads the refresh token from the cookie, hashes it, and compares against refresh_token_hash in the database.
  • If valid, new access token and refresh token are issued. New hash stored, old hash marked revoked, both tokens sent to client. The refresh token rotating on each use is what keeps the session sliding forward for active users.
  • If the refresh token is expired too, server rejects and forces re-login.
  • Reuse attack: a revoked refresh token being presented signals potential token theft. Server revokes all sessions for that user and forces re-login.

The Mistakes (And Why They're Easy to Make)

Mistake #1 : Long-Lived Tokens

If your token lives for days (or worse, weeks), you've already lost control over it.

Anyone who gets access to that token can act as the user until it expires.

No kill switch. No recall.

Fix: Use short-lived access tokens (5–15 minutes) with refresh tokens to maintain sessions.

Mistake #2 : Storing Tokens Carelessly

Where you store your token matters.

  • localStorage → vulnerable to XSS
  • sessionStorage → slightly better, still vulnerable
  • cookies → safer if configured properly

Fix: Use httpOnly, secure, sameSite cookies. Avoid exposing tokens to JavaScript when possible.

Mistake #3: Trusting the Payload Too Much

JWT payload is not encrypted. It's just base64 encoded. Anyone can decode it.

If you're putting sensitive data inside — emails, roles, permissions — assume it's visible. And if your verification is weak, it can be tampered with.

Fix: Keep payload minimal. Always verify signatures properly. Never trust client-sent claims blindly.

Mistake #4: Ignoring Revocation

This is where most implementations fall apart.

JWT is stateless — which means you can't "log out" a token unless you build extra systems. If a user logs out, changes password, or gets compromised: the token still works.

Fix approaches:

  • Use short-lived tokens
  • Rotate refresh tokens
  • Maintain a blacklist (if necessary)
  • Add token versioning (invalidate on password change)

Design decisions and why

  • Access token in memory, not localStorage localStorage is readable by any JS on the page, making it an easy XSS target. Memory is scoped to the tab and never exposed to the DOM.
  • Refresh token as an httpOnly cookie httpOnly cookies are fully inaccessible to JavaScript. The browser attaches them automatically, but no client-side code can read the value.
  • Storing the hash, not the plain refresh token A compromised database gives the attacker hashes, not usable tokens. Never store a secret in a form that works as-is.
  • 15-minute expiry on the access token Short expiry limits the damage if a token is stolen. 5 minutes causes unnecessary refresh churn. 1 hour widens the theft window considerably. 15 is the sweet spot.
  • 7-day expiry on the refresh token Covers a typical weekly usage pattern. With sliding sessions, active users never hit this limit. 30 days is common but 7 is the more conservative default.
  • Sliding session via refresh token rotation A fixed expiry abruptly logs out active users. Rotating on each use extends the session for active users and correctly expires idle ones.
  • Revoke all sessions on refresh token reuse A reused revoked token means a replay attack or a stolen token. You cannot tell which, so revoking everything is the only safe response.
  • Two tokens instead of one long-lived token The access token gives you stateless speed. The refresh token gives you session control. A single long-lived JWT gives you neither safety nor revocability.

When JWT Is the Wrong Choice

JWT is great when:

  • you need stateless scaling
  • multiple services verify tokens
  • you want to avoid session storage

But it's a poor choice when:

  • you need strict session control
  • you need instant revocation
  • your system is simple and centralized

Sometimes, a session-based system is just better.

JWT isn't the default. It's a trade-off.


A Better Mental Model

Think of JWT like this:

A signed permission slip that anyone holding it can use.

Not:

A controllable session stored on your server.

Once issued, it's out there. Your job is to limit how long it lives, how much power it has, and how easily it can be abused.


A Safer Baseline Setup

  • Short-lived access token (5–15 minutes)
  • Refresh token with rotation
  • httpOnly cookies
  • Signature verification on every request
  • Minimal payload
  • Optional revocation strategy

Not perfect. But significantly harder to mess up.


Final Thought

JWT isn't insecure.
But most implementations are.

They look correct. They pass tests. They work in production.
Until they don't.

And when they fail, they fail quietly.


Glossary

Top comments (0)