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 HttpOnly cookie 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 reads sub.
  • No CSRF middleware. Nothing is attached ambiently, so there is nothing to forge. The /auth/refresh endpoint 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 Authorization header.

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

Example · javascript
// 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.

Example · javascript
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.

Example · bash
# 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

  • Be the first to comment on this lesson.