node-auth · git:20260818.236909b · 2026-08-18 · sha256 9675312164769c1a
node-auth git:20260818.236909bA
Immutable. This exact content is served forever at /api/v1/blob/9675312164769c1a.
---
name: node-auth
description: Password and session-cookie authentication for a Node Web Only server, built entirely on node:crypto. Use when adding a login flow, hashing or verifying a password, issuing or validating a session, setting a cookie, or protecting a route. Triggers on scrypt, timingSafeEqual, createHmac, Set-Cookie, HttpOnly, SameSite, users.json, session tokens, and any mention of bcrypt, jsonwebtoken, or passport.
user-invocable: false
---
# Node Auth
**scrypt for passwords, an HMAC-signed token for sessions, and an `HttpOnly` cookie to carry it. No auth framework and no database.**
`node:crypto` covers all of it, so the login path adds no dependency, no middleware chain, and no session store to operate.
## Password Hashing
`scrypt` is the memory-hard key derivation function `node:crypto` exposes. Memory hardness is what makes a stolen hash expensive to attack on a GPU, which a plain SHA-256 is not.
Each password gets a fresh 16-byte salt, and the stored value is `salt:derivedKey` in hex. A per-user salt means two users with the same password produce different hashes, so one cracked hash reveals nothing about the other account.
Verification compares with `timingSafeEqual`, because a normal comparison returns faster the earlier it finds a mismatched byte, and that timing difference is enough to recover the expected value one byte at a time.
```js
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);
const KEY_LEN = 64;
export async function hashPassword(password) {
const salt = randomBytes(16);
const derived = await scryptAsync(password, salt, KEY_LEN);
return `${salt.toString('hex')}:${derived.toString('hex')}`;
}
export async function verifyPassword(password, stored) {
const [saltHex, keyHex] = stored.split(':');
if (!saltHex || !keyHex) return false;
const expected = Buffer.from(keyHex, 'hex');
const derived = await scryptAsync(password, Buffer.from(saltHex, 'hex'), expected.length);
return expected.length === derived.length && timingSafeEqual(expected, derived);
}
```
The length is checked before `timingSafeEqual`, which throws on mismatched buffer lengths rather than returning false.
## Sessions
A session is a signed token rather than an entry in a server-side store, so there is nothing to expire, evict, or replicate.
The signing secret is generated with `randomBytes(32)` at boot and never written to disk. Every restart mints a new secret and invalidates all outstanding sessions, which is the accepted trade-off for a single-process app: a persisted secret would outlive the process and need rotating by hand.
The token is `base64url(payload).hmacSignature`. Validation recomputes the HMAC, compares it with `timingSafeEqual`, and only then parses the payload and checks expiry. Verifying before parsing means attacker-controlled bytes never reach `JSON.parse` unless they were signed by this process.
```js
import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto';
const SESSION_SECRET = randomBytes(32);
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
function sign(body) {
return createHmac('sha256', SESSION_SECRET).update(body).digest('base64url');
}
export function createSession(username) {
const payload = JSON.stringify({ username, exp: Date.now() + SESSION_TTL_MS });
const body = Buffer.from(payload).toString('base64url');
return `${body}.${sign(body)}`;
}
export function validateSession(token) {
if (!token) return null;
const [body, sig] = token.split('.');
if (!body || !sig) return null;
const expected = sign(body);
const given = Buffer.from(sig);
if (given.length !== expected.length || !timingSafeEqual(given, Buffer.from(expected))) {
return null;
}
let claims;
try {
claims = JSON.parse(Buffer.from(body, 'base64url').toString());
} catch {
return null;
}
return claims.exp > Date.now() ? claims.username : null;
}
```
Every failure path returns `null` rather than throwing, so a caller has one check instead of a `try` around every validation.
## Cookies
```js
const MAX_AGE = 12 * 60 * 60;
export function sessionCookie(token) {
return `session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${MAX_AGE}`;
}
export function clearedCookie() {
return `session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
}
export function readCookie(req, name) {
const header = req.headers.cookie;
if (!header) return null;
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
}
return null;
}
```
| Attribute | Why |
|---|---|
| `HttpOnly` | keeps the token out of `document.cookie`, so a script injected into the page cannot read it |
| `SameSite=Lax` | blocks the cookie on cross-site subrequests while still allowing top-level navigation, which is CSRF mitigation without breaking inbound links |
| `Path=/` | the whole app is behind the same session |
| `Max-Age` | bounds how long a stolen cookie is useful |
`Secure` is added whenever the server sits behind TLS, which stops the cookie from ever crossing a plaintext connection.
Clearing sets `Max-Age=0` with the same attributes, since a browser only replaces a cookie when the name, path, and domain all match.
## users.json
Credentials live in a JSON file the server reads and never writes.
```json
{
"admin": { "password": "a1b2...:9f8e..." }
}
```
The file is re-read on every login attempt rather than cached at boot, so an operator can add or remove a user without restarting the process.
An unknown username still runs a verification against a fixed dummy hash. Skipping it would return noticeably faster than a wrong password does, and that difference tells an attacker which usernames exist.
```js
import { readFile } from 'node:fs/promises';
const DUMMY_HASH = '0'.repeat(32) + ':' + '0'.repeat(128);
export async function authenticate(usersFile, username, password) {
const users = JSON.parse(await readFile(usersFile, 'utf8'));
const record = users[username];
if (!record) {
await verifyPassword(password, DUMMY_HASH);
return false;
}
return verifyPassword(password, record.password);
}
```
Stored values are produced with `hashPassword`, from a one-off script or an admin endpoint. A plaintext password in the file defeats every other measure here.
## Wiring
The helpers throw or return, and the request handler decides the HTTP response. Keeping the decision at the boundary is what lets the same `authenticate` back a login form, a token endpoint, and a test.
```js
import { authenticate, createSession, sessionCookie, readCookie, validateSession } from './auth.js';
export async function handleLogin(req, res, body, config) {
const ok = await authenticate(config.usersFile, body.username, body.password);
if (!ok) {
res.writeHead(401).end();
return;
}
res.writeHead(204, { 'Set-Cookie': sessionCookie(createSession(body.username)) }).end();
}
export function requireSession(req) {
return validateSession(readCookie(req, 'session'));
}
```
A failed login returns a bare 401 with no message distinguishing an unknown user from a wrong password, so the response leaks no more than the timing already does not.