Menu
CoddyTech

What a JWT Is

Part of the Auth and Security section of Coddy's Node.js journey. Lesson 14 of 24.

A session needs a database lookup on every request. A JWT (JSON Web Token) carries the facts itself: three parts joined by dots. Each part is written in base64url, a way to write bytes with letters, digits, - and _. The header names the algorithm, the payload holds the claims (sub for whose token it is, exp for when it expires), and the signature proves the server made it.

Base64url is an encoding, not encryption. Anyone holding a token can read its payload:

const payload = token.split('.')[1];
JSON.parse(Buffer.from(payload, 'base64url').toString());
// { sub: '42', name: 'Lin', exp: 1767225600 }

decodeJwt(token) from jose, the JWT package, does the same. It reads the payload without checking the signature, and throws when the text is not a JWT. So never put a secret in a payload, and never decide anything from a claim decodeJwt gave you: anyone can write any claim.

challenge icon

Challenge

Easy

This chapter adds a second way to log in, with tokens, for apps that call your API. It starts from a smaller copy of the server, with signup and login but no sessions or notes. A JWT carries its facts inside it, and anyone can read them. Build a route that shows what a token says, and close an admin page that trusts whatever a token claims.

  1. In server.js, write GET /inspect. It reads a token from the query string, as in GET /inspect?token=eyJ..., and returns the claims inside it as JSON. Read them with decodeJwt, which is already imported. For a token whose payload is {"sub": "lin", "plan": "free"}, the route returns exactly {"sub": "lin", "plan": "free"}.
  2. decodeJwt throws an error when the text is not a JWT. Catch it and respond with status 400 and {"error": "Not a JWT"}. For example, GET /inspect?token=hello gets that 400.
  3. Look at GET /admin. It reads the token with decodeJwt and shows a secret when the token says "admin": true. But decodeJwt never checks the signature, so anyone can type a token that says "admin": true. This server cannot check signatures yet. So make GET /admin respond with status 403 and {"error": "Admins only"} to every request, whatever token it gets. 403 Forbidden means "you may not see this". Chapter 4 opens an admin page again, for real admins only.

Try it: Press Run. The request bar starts on GET /admin?token= followed by this token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYWxsb3J5IiwiYWRtaW4iOnRydWV9.c2lnbmF0dXJl An attacker called mallory wrote it by hand, with no secret key. Send it before you change anything: the starter returns {"secret": "The vault key is 42"}. After your change, Run again and send the same request: status 403 and {"error": "Admins only"}. Send GET /inspect?token= followed by the same token: {"sub": "mallory", "admin": true}, read without any key. Send GET /inspect?token=hello: status 400 and {"error": "Not a JWT"}.

Try it yourself

import express from 'express';
import { decodeJwt } from 'jose';
import { users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';

const app = express();
app.use(express.json());

app.post('/signup', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const password = String(req.body.password ?? '');
    if (!username || password.length < 8) {
        return res.status(400).json({ error: 'Pick a username and a password of at least 8 characters' });
    }
    if (await users.findOne({ username })) {
        return res.status(409).json({ error: 'That username is taken' });
    }
    await users.insertOne({ username, passwordHash: await hashPassword(password) });
    res.status(201).json({ username });
});

app.post('/login', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const user = await users.findOne({ username });
    if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
        return res.status(401).json({ error: 'Wrong username or password' });
    }
    res.json({ username: user.username });
});

// Shows what a token says: GET /inspect?token=...
app.get('/inspect', (req, res) => {
    res.status(501).json({ error: 'Not written yet' });
});

// The admin page.
app.get('/admin', (req, res) => {
    const claims = decodeJwt(String(req.query.token ?? ''));
    if (claims.admin === true) {
        return res.json({ secret: 'The vault key is 42' });
    }
    res.status(403).json({ error: 'Admins only' });
});

app.listen(3000, () => {
    console.log('Vault is running on port 3000');
});
quiz iconTest yourself

This lesson includes a short quiz. Start the lesson to answer it and track your progress.

All lessons in Auth and Security

Practice on your own: Express playground