Building the Token API on dfg.com
The same notes API, reissued with JWT access tokens and a rotating refresh token.
Same app, same endpoints, same database. What changes is the credential: instead of a session cookie the client receives a signed access token, and instead of a session store the API verifies a signature.
What the server now issues
- An access token — a JWT, 10 minutes, returned in the JSON body. The client keeps it in memory.
- A refresh token — an opaque random string, 30 days, set as an
HttpOnlycookie scoped to/auth/refresh, and stored server-side as a hash.
That split places each credential where its risk is lowest: the short-lived one somewhere JavaScript can use it, the long-lived one somewhere JavaScript cannot read it.
What disappears from the code
- No session store on the hot path —
/api/*verifies a signature and readssub. - No CSRF middleware. Nothing is attached ambiently, so there is nothing to forge. The
/auth/refreshendpoint is the one exception worth thinking about, and it is protected by being cookie-scoped,SameSite-restricted and rotation-checked.
What appears instead
- Key management — an HS256 secret here for brevity; RS256 with a JWKS endpoint once more than one service verifies.
- The refresh table, with a family id for rotation and reuse detection.
- A CORS config that allows the
Authorizationheader.
The CORS difference
API calls no longer need credentials: true — they carry no cookie. Only /auth/login, /auth/refresh and /auth/logout do, because those three set or read the refresh cookie. Keeping credentials off the bulk of your surface area is a small but real reduction in exposure.
Example
// Two credentials, two very different homes
const accessToken = jwt.sign(
{ sub: user.id },
ACCESS_SECRET,
{ algorithm: 'HS256', expiresIn: '10m',
issuer: 'https://dfg.com', audience: 'https://dfg.com/api', jwtid: randomUUID() },
);
// → response body → the client holds it in a variable
res.cookie('rt', refreshToken, {
httpOnly: true, // JavaScript can never read it
secure: true,
sameSite: 'none', // abc.com → dfg.com is cross-site
partitioned: true,
path: '/auth/refresh', // sent to exactly one endpoint
maxAge: 30 * 24 * 3600e3,
});When to use it
- A team migrating from cookies keeps every route and only swaps the credential layer, so the diff is reviewable in one sitting.
- An API drops CSRF middleware entirely after moving to bearer tokens, removing a class of bug rather than defending against it.
- A mobile client is added later with no server changes at all, because the token scheme was never browser-specific.
More examples
api/server.js — the complete token API
Compare this with the cookie version: the routes are byte-for-byte identical below /api. Only how req.user.id is established changed, which is exactly the point.
import express from 'express';
import https from 'https';
import fs from 'fs';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import jwt from 'jsonwebtoken';
import { randomBytes, createHash, randomUUID } from 'crypto';
import { db, publicProfile } from './db.js';
const app = express();
const FRONTEND = 'https://abc.local:5173';
const ISSUER = 'https://dfg.local:8443';
const AUDIENCE = 'https://dfg.local:8443/api';
const ACCESS_SECRET = process.env.ACCESS_SECRET; // >= 32 random bytes
const ACCESS_TTL = '10m';
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
if (!ACCESS_SECRET || ACCESS_SECRET.length < 32) {
throw new Error('ACCESS_SECRET must be at least 32 characters');
}
/* ------------------------------ CORS ------------------------------ */
app.use(cors({
origin: [FRONTEND],
allowedHeaders: ['Content-Type', 'Authorization'], // ← Authorization matters
credentials: true, // only /auth/* actually uses the cookie
methods: ['GET', 'POST', 'DELETE'],
maxAge: 86400,
}));
app.use(express.json({ limit: '16kb' }));
app.use(cookieParser());
app.set('trust proxy', 1);
/* ------------------------- token helpers -------------------------- */
const issueAccessToken = (userId) => jwt.sign({ sub: userId }, ACCESS_SECRET, {
algorithm: 'HS256', expiresIn: ACCESS_TTL,
issuer: ISSUER, audience: AUDIENCE, jwtid: randomUUID(),
});
const refreshTokens = new Map(); // hash -> row (use a table in production)
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
function issueRefreshToken(userId, familyId = randomUUID(), familyExpiresAt = null) {
const token = randomBytes(32).toString('base64url');
refreshTokens.set(sha256(token), {
userId, familyId,
familyExpiresAt: familyExpiresAt ?? new Date(Date.now() + REFRESH_TTL_MS),
usedAt: null, revokedAt: null,
});
return token;
}
const REFRESH_COOKIE_OPTIONS = {
httpOnly: true, secure: true, sameSite: 'none', partitioned: true,
path: '/auth/refresh', maxAge: REFRESH_TTL_MS,
};
/* -------------------------- auth middleware ----------------------- */
function bearerAuth(req, res, next) {
const header = req.get('authorization') ?? '';
const token = /^bearer /i.test(header) ? header.slice(7).trim() : null;
if (!token) return unauthorized(res, 'authentication_required');
try {
const claims = jwt.verify(token, ACCESS_SECRET, {
algorithms: ['HS256'], // pinned: never read the alg from the token
issuer: ISSUER,
audience: AUDIENCE,
clockTolerance: 30,
});
req.user = { id: claims.sub };
next();
} catch (err) {
return unauthorized(res,
err.name === 'TokenExpiredError' ? 'token_expired' : 'invalid_token');
}
}
function unauthorized(res, error) {
res.set('WWW-Authenticate', `Bearer realm="api", error="${error}"`);
return res.status(401).json({ error });
}
/* ----------------------------- routes ----------------------------- */
const authLimiter = rateLimit({ windowMs: 15 * 60e3, limit: 20,
message: { error: 'too_many_attempts' } });
app.post('/auth/register', authLimiter, async (req, res) => {
const { email, password } = req.body ?? {};
if (!email || !password || String(password).length < 12) {
return res.status(400).json({ error: 'email_and_12_char_password_required' });
}
try {
const user = await db.createUser(email, password);
res.cookie('rt', issueRefreshToken(user.id), REFRESH_COOKIE_OPTIONS);
res.status(201).json({
user: publicProfile(user),
accessToken: issueAccessToken(user.id),
expiresIn: 600,
});
} catch {
res.status(400).json({ error: 'registration_failed' });
}
});
app.post('/auth/login', authLimiter, async (req, res) => {
const user = await db.verifyPassword(req.body?.email, req.body?.password);
if (!user) return res.status(401).json({ error: 'invalid_credentials' });
res.cookie('rt', issueRefreshToken(user.id), REFRESH_COOKIE_OPTIONS);
res.json({
user: publicProfile(user),
accessToken: issueAccessToken(user.id),
expiresIn: 600,
});
});
/* -------------------------- protected API ------------------------- */
app.get('/api/me', bearerAuth, (req, res) => {
const user = db.findUserById(req.user.id);
if (!user) return unauthorized(res, 'invalid_token');
res.json({ user: publicProfile(user) });
});
app.get('/api/notes', bearerAuth, (req, res) => {
res.json({ notes: db.listNotes(req.user.id) });
});
app.post('/api/notes', bearerAuth, (req, res) => {
const text = String(req.body?.text ?? '').trim();
if (!text || text.length > 500) return res.status(400).json({ error: 'invalid_text' });
res.status(201).json({ note: db.createNote(req.user.id, text) });
});
app.delete('/api/notes/:id', bearerAuth, (req, res) => {
return db.deleteNote(req.user.id, req.params.id)
? res.sendStatus(204)
: res.status(404).json({ error: 'not_found' });
});
https.createServer({
key: fs.readFileSync('../dfg.local-key.pem'),
cert: fs.readFileSync('../dfg.local.pem'),
}, app).listen(8443, () => console.log('Token API on https://dfg.local:8443'));
export { refreshTokens, issueRefreshToken, issueAccessToken, sha256,
REFRESH_COOKIE_OPTIONS, app };Exercising it with curl
Decoding your own token in one command is the habit worth forming: it makes leaked-claim mistakes obvious long before a security review finds them.
# Log in — the token comes back in the BODY, the refresh in a cookie
RESP=$(curl -s -c jar.txt -X POST https://dfg.local:8443/auth/login \
-H 'Content-Type: application/json' \
-H 'Origin: https://abc.local:5173' \
-d '{"email":"[email protected]","password":"correct horse battery"}' \
--cacert ../rootCA.pem)
TOKEN=$(echo "$RESP" | jq -r .accessToken)
# Inspect what you were given (no key needed — it is signed, not encrypted)
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
# { "sub": "…", "iat": …, "exp": …, "iss": "https://dfg.local:8443",
# "aud": "https://dfg.local:8443/api", "jti": "…" }
# Call the API — no cookie involved at all
curl -s https://dfg.local:8443/api/notes \
-H "Authorization: Bearer $TOKEN" --cacert ../rootCA.pem | jq
# Tamper with one character of the payload → the signature no longer matches
BAD=$(echo "$TOKEN" | sed 's/./X/60')
curl -i -s https://dfg.local:8443/api/notes \
-H "Authorization: Bearer $BAD" --cacert ../rootCA.pem | head -3
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Bearer realm="api", error="invalid_token"
# Wait past the expiry
sleep 601
curl -s https://dfg.local:8443/api/notes \
-H "Authorization: Bearer $TOKEN" --cacert ../rootCA.pem | jq
# { "error": "token_expired" } ← the client's cue to refresh
Discussion