Build a Minimal JWT Authentication Service
A small authentication service that issues HMAC-signed JSON Web Tokens, verifies them on protected routes, enforces expiry, and rejects tampered tokens with clear errors.
Project goal
Build an auth service that signs and verifies JWTs with HMAC-SHA256, protects routes with a bearer token, enforces expiry and issuer claims, and returns precise errors for each failure mode.
Final features
- Issue a signed token with subject, issuer, and expiry claims.
- Verify signature, expiry, and issuer on every protected request.
- Reject tampered payloads and wrong keys with specific error codes.
- Protect a route that returns the authenticated subject.
- Expose token issuance via a login endpoint.
Architecture
A claims module builds and validates the claim set; a signer produces and parses the compact JWS serialisation using node:crypto; a verifier checks the signature in constant time and then the time-based claims; and middleware attaches the subject to the request. No third-party JWT library is used so each step is visible.
Prerequisites
- Node.js 20 LTS or later
- Understanding of HMAC and base64url encoding
- Basic familiarity with HTTP bearer tokens
Dependencies
- node >=20 — node:crypto for HMAC and timing-safe comparison
- vitest ^2.1.0 — Sign, verify, and failure-mode tests
Directory structure
jwt-service/
├── src/
│ ├── base64url.js
│ ├── sign.js
│ ├── verify.js
│ ├── middleware.js
│ └── server.js
├── test/
│ ├── sign.test.js
│ └── verify.test.js
└── package.json
Phases
1. Base64url helpers
Encode and decode JSON to base64url without padding, matching the JWT serialisation exactly.
Files: src/base64url.js
export function encodeJson(value) {
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
}
export function decodeJson(segment) {
const json = Buffer.from(segment, 'base64url').toString('utf8');
return JSON.parse(json);
}Expected behavior: Encoding an object then decoding it returns the same object, and the output contains no +, /, or = characters.
2. Claim construction
Build a standard claim set with subject, issuer, issued-at, and expiry, and validate required claims on the way back.
Files: src/claims.js
export function buildClaims(subject, { issuer, ttlSeconds = 900, now = Date.now() }) {
const iat = Math.floor(now / 1000);
return { sub: subject, iss: issuer, iat, exp: iat + ttlSeconds };
}
export function validateClaims(claims, { issuer, now = Date.now(), clockSkew = 30 }) {
if (claims.iss !== issuer) return { ok: false, code: 'bad_issuer' };
const seconds = Math.floor(now / 1000);
if (typeof claims.exp !== 'number' || seconds > claims.exp + clockSkew) {
return { ok: false, code: 'expired' };
}
return { ok: true };
}Expected behavior: A token built with a 15-minute ttl is valid immediately, expired an hour later, and rejected with bad_issuer when the issuer differs.
3. Signing
Produce the compact JWS serialisation with an HMAC-SHA256 signature over the header and payload.
Files: src/sign.js
import { createHmac } from 'node:crypto';
import { encodeJson } from './base64url.js';
export function sign(payload, secret) {
const header = encodeJson({ alg: 'HS256', typ: 'JWT' });
const body = encodeJson(payload);
const data = header + '.' + body;
const signature = createHmac('sha256', secret).update(data).digest('base64url');
return data + '.' + signature;
}Expected behavior: Two calls with the same payload and secret produce identical tokens, and a different secret produces a different third segment.
4. Verification
Split the token, recompute the signature, compare it in constant time, then validate the claims.
Files: src/verify.js
import { createHmac, timingSafeEqual } from 'node:crypto';
import { decodeJson } from './base64url.js';
import { validateClaims } from './claims.js';
export function verify(token, secret, { issuer } = {}) {
const parts = token.split('.');
if (parts.length !== 3) return { ok: false, code: 'malformed' };
const data = parts[0] + '.' + parts[1];
const expected = createHmac('sha256', secret).update(data).digest();
const provided = Buffer.from(parts[2], 'base64url');
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
return { ok: false, code: 'bad_signature' };
}
const claims = decodeJson(parts[1]);
const check = validateClaims(claims, { issuer });
return check.ok ? { ok: true, claims } : check;
}Expected behavior: A valid token verifies, a token with one flipped payload byte returns bad_signature, and an expired token returns expired.
5. Protected route middleware
Extract the bearer token, verify it, and attach the subject to the request or return 401 with the specific code.
Files: src/middleware.js
import { verify } from './verify.js';
export function authenticate(req, { secret, issuer }) {
const header = req.headers.authorization ?? '';
const match = /^Bearer (.+)$/.exec(header);
if (!match) return { ok: false, status: 401, body: { error: 'missing_token' } };
const result = verify(match[1], secret, { issuer });
if (!result.ok) return { ok: false, status: 401, body: { error: result.code } };
return { ok: true, subject: result.claims.sub };
}Expected behavior: A request without a bearer header returns missing_token, a tampered token returns bad_signature, and a valid token yields the subject.
6. Server and tests
Wire a login endpoint and a protected endpoint, then cover signing, verification, and every failure code with tests.
Files: src/server.js
import { createServer } from 'node:http';
import { sign } from './sign.js';
import { buildClaims } from './claims.js';
import { authenticate } from './middleware.js';
const SECRET = process.env.JWT_SECRET ?? 'dev-only-secret';
const ISSUER = 'jwt-service';
const server = createServer((req, res) => {
if (req.url === '/login' && req.method === 'POST') {
const claims = buildClaims('user-1', { issuer: ISSUER });
return send(res, 200, { token: sign(claims, SECRET) });
}
if (req.url === '/me') {
const auth = authenticate(req, { secret: SECRET, issuer: ISSUER });
return auth.ok ? send(res, 200, { sub: auth.subject }) : send(res, auth.status, auth.body);
}
send(res, 404, { error: 'not_found' });
});
function send(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
server.listen(process.env.PORT ?? 3000);
export { server };Expected behavior: Login returns a token, calling /me with that token returns the subject, and calling /me without it returns 401 missing_token.
Testing
Run npx vitest. Signing tests assert the token has three segments and that changing one byte of the payload invalidates it. Verification tests cover valid tokens, expired tokens, wrong-issuer tokens, and tokens signed with a different secret, each asserting a distinct error code. A timing test asserts the comparison uses the timing-safe function rather than string equality.
Troubleshooting
Valid tokens are rejected after a secret rotation
Expect verification to fail once the secret changes; support two accepted secrets during a rotation window keyed by a kid header, then retire the old one.
The signature check sometimes accepts tampered tokens
Never compare signatures with string equality; compare the decoded bytes with timingSafeEqual and check lengths first, which also avoids leaking position information.
Tokens appear valid immediately after expiry
Allow a small clock-skew allowance and account for it explicitly; rejecting at the exact second invites flaky failures between services with slightly different clocks.
Deployment
Run behind TLS and store the secret in a secrets manager, not in the repository. In a multi-service setting, publish the issuer and accepted algorithms so every verifier agrees on what a valid token means.
Known limitations
- HMAC means every verifier also holds the signing secret; asymmetric signing is the upgrade path when verifiers should not be able to mint tokens.
- There is no revocation list, so a stolen token stays valid until it expires.
Project overview
JWTs look simple and are easy to get subtly wrong. This project implements the compact serialisation from scratch so the signature, the encoding, and the time checks are each inspectable, and then pins the failure modes with tests.
The service issues tokens on login, verifies them on a protected route, and distinguishes an expired token from a tampered one or a wrong key through explicit error codes.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
Signing is three base64url segments with an HMAC over the first two, which is the entire mechanism in a few lines:
import { createHmac } from 'node:crypto';
export function sign(payload, secret, { header = { alg: 'HS256', typ: 'JWT' } } = {}) {
const encodedHeader = encodeJson(header);
const encodedPayload = encodeJson(payload);
const data = encodedHeader + '.' + encodedPayload;
const signature = createHmac('sha256', secret).update(data).digest('base64url');
return data + '.' + signature;
}
Verification reverses the same steps and compares the signature with a timing-safe check, which is where a naive implementation leaks information.
Setup
This project deliberately uses node:crypto rather than a JWT package so the tutorial can show the signature bytes, the encoding, and the claim checks without indirection.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | node:crypto for HMAC and timing-safe comparison |
| vitest | ^2.1.0 | Sign, verify, and failure-mode tests |
Create the project and install:
mkdir jwt-service && cd jwt-service
npm init -y
npm install -D vitest@^2.1.0
Configure package.json with the server entry and tests:
{
"name": "jwt-service",
"type": "module",
"scripts": {
"start": "node src/server.js",
"test": "vitest run"
}
}
Read the signing secret from an environment variable and never commit a default value; tests pass an explicit secret of their own.
Phase 1: Base64url helpers
Encode and decode JSON to base64url without padding, matching the JWT serialisation exactly. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
export function encodeJson(value) {
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
}
export function decodeJson(segment) {
const json = Buffer.from(segment, 'base64url').toString('utf8');
return JSON.parse(json);
}
Acceptance for this phase: Encoding an object then decoding it returns the same object, and the output contains no +, /, or = characters.
Phase 2: Claim construction
Build a standard claim set with subject, issuer, issued-at, and expiry, and validate required claims on the way back. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
export function buildClaims(subject, { issuer, ttlSeconds = 900, now = Date.now() }) {
const iat = Math.floor(now / 1000);
return { sub: subject, iss: issuer, iat, exp: iat + ttlSeconds };
}
export function validateClaims(claims, { issuer, now = Date.now(), clockSkew = 30 }) {
if (claims.iss !== issuer) return { ok: false, code: 'bad_issuer' };
const seconds = Math.floor(now / 1000);
if (typeof claims.exp !== 'number' || seconds > claims.exp + clockSkew) {
return { ok: false, code: 'expired' };
}
return { ok: true };
}
Acceptance for this phase: A token built with a 15-minute ttl is valid immediately, expired an hour later, and rejected with bad_issuer when the issuer differs.
Phase 3: Signing
Produce the compact JWS serialisation with an HMAC-SHA256 signature over the header and payload. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
import { createHmac } from 'node:crypto';
import { encodeJson } from './base64url.js';
export function sign(payload, secret) {
const header = encodeJson({ alg: 'HS256', typ: 'JWT' });
const body = encodeJson(payload);
const data = header + '.' + body;
const signature = createHmac('sha256', secret).update(data).digest('base64url');
return data + '.' + signature;
}
Acceptance for this phase: Two calls with the same payload and secret produce identical tokens, and a different secret produces a different third segment.
Phase 4: Verification
Split the token, recompute the signature, compare it in constant time, then validate the claims. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { decodeJson } from './base64url.js';
import { validateClaims } from './claims.js';
export function verify(token, secret, { issuer } = {}) {
const parts = token.split('.');
if (parts.length !== 3) return { ok: false, code: 'malformed' };
const data = parts[0] + '.' + parts[1];
const expected = createHmac('sha256', secret).update(data).digest();
const provided = Buffer.from(parts[2], 'base64url');
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
return { ok: false, code: 'bad_signature' };
}
const claims = decodeJson(parts[1]);
const check = validateClaims(claims, { issuer });
return check.ok ? { ok: true, claims } : check;
}
Acceptance for this phase: A valid token verifies, a token with one flipped payload byte returns bad_signature, and an expired token returns expired.
Phase 5: Protected route middleware
Extract the bearer token, verify it, and attach the subject to the request or return 401 with the specific code. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
import { verify } from './verify.js';
export function authenticate(req, { secret, issuer }) {
const header = req.headers.authorization ?? '';
const match = /^Bearer (.+)$/.exec(header);
if (!match) return { ok: false, status: 401, body: { error: 'missing_token' } };
const result = verify(match[1], secret, { issuer });
if (!result.ok) return { ok: false, status: 401, body: { error: result.code } };
return { ok: true, subject: result.claims.sub };
}
Acceptance for this phase: A request without a bearer header returns missing_token, a tampered token returns bad_signature, and a valid token yields the subject.
Phase 6: Server and tests
Wire a login endpoint and a protected endpoint, then cover signing, verification, and every failure code with tests. Because the phase is complete on its own, the tutorial can be paused and resumed at its boundaries without losing the thread.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
import { createServer } from 'node:http';
import { sign } from './sign.js';
import { buildClaims } from './claims.js';
import { authenticate } from './middleware.js';
const SECRET = process.env.JWT_SECRET ?? 'dev-only-secret';
const ISSUER = 'jwt-service';
const server = createServer((req, res) => {
if (req.url === '/login' && req.method === 'POST') {
const claims = buildClaims('user-1', { issuer: ISSUER });
return send(res, 200, { token: sign(claims, SECRET) });
}
if (req.url === '/me') {
const auth = authenticate(req, { secret: SECRET, issuer: ISSUER });
return auth.ok ? send(res, 200, { sub: auth.subject }) : send(res, auth.status, auth.body);
}
send(res, 404, { error: 'not_found' });
});
function send(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
server.listen(process.env.PORT ?? 3000);
export { server };
Acceptance for this phase: Login returns a token, calling /me with that token returns the subject, and calling /me without it returns 401 missing_token.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. Signing tests assert the token has three segments and that changing one byte of the payload invalidates it. Verification tests cover valid tokens, expired tokens, wrong-issuer tokens, and tokens signed with a different secret, each asserting a distinct error code. A timing test asserts the comparison uses the timing-safe function rather than string equality.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
Distinct error codes are the point: an expired token should prompt a refresh, while a tampered token should prompt re-authentication, and the caller cannot tell them apart from a generic failure.
Extending the project
Role-based claims extend the claim set and add one check in middleware; refresh tokens are a second signed artefact with a longer expiry; and swapping HMAC for asymmetric signing replaces only the sign and verify primitives because the rest operates on the compact string.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.