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
EasyThis 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.
- In
server.js, writeGET /inspect. It reads a token from the query string, as inGET /inspect?token=eyJ..., and returns the claims inside it as JSON. Read them withdecodeJwt, which is already imported. For a token whose payload is{"sub": "lin", "plan": "free"}, the route returns exactly{"sub": "lin", "plan": "free"}. decodeJwtthrows 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=hellogets that 400.- Look at
GET /admin. It reads the token withdecodeJwtand shows a secret when the token says"admin": true. ButdecodeJwtnever checks the signature, so anyone can type a token that says"admin": true. This server cannot check signatures yet. So makeGET /adminrespond 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');
});
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