Harden JWT auth: require strong secret, expire tokens

Security review flagged a hardcoded JWT secret fallback. A booth machine
started without JWT_SECRET would have signed tokens with a publicly-known
default, letting anyone forge an admin token — defeating the local-auth
anti-fraud model.

- requireJwtSecret() refuses to start on a missing, <32-char, or placeholder
  secret (no insecure default).
- Add sign.expiresIn: 8h so minted tokens expire (bound to a shift).
- Add apps/server/.env.example documenting JWT_SECRET + how to generate it.

Verified: refuses with no secret and with the old placeholder; boots and
serves /health with a valid `openssl rand -hex 32` secret.
This commit is contained in:
2026-06-14 07:42:22 +02:00
parent bfe64032d8
commit 94dd3fcff4
2 changed files with 32 additions and 1 deletions
+11
View File
@@ -0,0 +1,11 @@
# Copy to .env and fill in. The server refuses to start without a strong JWT_SECRET.
#
# Generate a strong secret:
# openssl rand -hex 32
JWT_SECRET=
# Optional
# PORT=3000
# HOST=0.0.0.0
# LOG_LEVEL=info
# DATABASE_URL=./parking.sqlite
+21 -1
View File
@@ -13,14 +13,34 @@ declare module "@fastify/jwt" {
}
}
/**
* Resolve the JWT signing secret, refusing to start without a strong one.
* There is deliberately no fallback default — a missing, short, or placeholder
* secret throws so the server never runs with forgeable tokens.
*/
function requireJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret || secret.length < 32 || /change.?me|insecure|dev-only/i.test(secret)) {
throw new Error(
"JWT_SECRET must be set to a strong random value (>=32 chars). " +
"Generate one with: openssl rand -hex 32",
);
}
return secret;
}
export async function buildServer(): Promise<FastifyInstance> {
const app = Fastify({
logger: { level: process.env.LOG_LEVEL ?? "info" },
});
// Local JWT signing with a local secret — no external identity provider.
// Fail fast rather than fall back to a known default: a booth machine started
// without a real secret would sign tokens anyone could forge (incl. an admin
// token), defeating the whole local-auth/anti-fraud model. No insecure default.
await app.register(jwt, {
secret: process.env.JWT_SECRET ?? "dev-only-insecure-secret-change-me",
secret: requireJwtSecret(),
sign: { expiresIn: "8h" }, // bound to a shift; minted tokens must expire
});
app.get("/health", async () => ({ status: "ok" }));