Signing Tokens
Part of the Auth and Security section of Coddy's Node.js journey. Lesson 15 of 24.
A token is only worth something if nobody else can make one. With HS256, jose mixes the header and payload with a secret only the server knows, and hashes the result. That hash is the signature. Without the secret, nobody can make one that matches.
The secret comes from an environment variable, process.env.JWT_SECRET, never from the code: code is shared, copied and committed. This lesson sets JWT_SECRET for you. jose wants it as bytes.
const key = new TextEncoder().encode(process.env.INVITE_SECRET);
const invite = await new SignJWT({ room: 'design' })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(guestId)
.setIssuedAt()
.setExpirationTime('2d')
.sign(key);setSubject takes text, and setExpirationTime takes a span like '30m' or '2d'.
Challenge
EasyApps that call your API, like a phone app, do not keep cookies the way a browser does. Give them a token instead: a signed JWT that they send back with each request. POST /token is the login for those apps.
- In
server.js, importSignJWTfromjosenext todecodeJwt. Above the routes, turnprocess.env.JWT_SECRETinto bytes with aTextEncoder, as the lesson shows, becausejosesigns with bytes. This lesson setsJWT_SECRETfor you. - Add
POST /token. It receives the same body as login,{"username": "ada", "password": "correct horse"}, and checks the username and password the same wayPOST /logindoes. When they are wrong, respond with status 401 and{"error": "Wrong username or password"}. - When they are right, sign a token and respond with it as
{"token": "..."}. The token must:- use the algorithm HS256, signed with the
JWT_SECRETbytes; - hold the claim
usernamein its payload; - have the user's
_idas text as its subject. UsetoHexString(), becausejoserefuses a subject that is not text; - expire one hour after it is made.
A token from this lesson looks like this:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkYSIsInN1YiI6IjY2ZjFjMmE5ZTRiMGExYjJjM2Q0ZTVmNiIsImlhdCI6MTc5MzM2MTYwMCwiZXhwIjoxNzkzMzY1MjAwfQ.EdCrjAKSjZowcjE3-IjKttBLVK1VKKbL9DPnJDLdgug - use the algorithm HS256, signed with the
Try it: Press Run. Send POST /signup with {"username": "ada", "password": "correct horse"}. Send POST /token with the same body: status 200 and {"token": "..."}. Copy the token and send GET /inspect?token= followed by it. You see something like {"username": "ada", "sub": "66f1c2a9e4b0a1b2c3d4e5f6", "iat": 1793361600, "exp": 1793365200}: exp is 3600 seconds after iat. Send POST /token with {"username": "ada", "password": "wrong horse"}: status 401.
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) => {
try {
res.json(decodeJwt(String(req.query.token ?? '')));
} catch {
res.status(400).json({ error: 'Not a JWT' });
}
});
// The admin page. No claim can be trusted before its signature is checked.
app.get('/admin', (req, res) => {
res.status(403).json({ error: 'Admins only' });
});
app.listen(3000, () => {
console.log('Vault is running on port 3000');
});
All lessons in Auth and Security
Practice on your own: Express playground