@wess/atlas/security#

Security primitives every web app needs: response-hardening headers, rate limiting, audit logging, TOTP/2FA, and revocable JWT sessions.

Modules#

SubmoduleWhat it gives you
headerswithSecurityHeaders — strict default headers + CSP, plus a peer-IP shim that stashes req.peerIp for clientIp to read
inlinedecideInline — safe-MIME allowlist for Content-Disposition: inline decisions
ratelimitcreateDbRateLimit / createMemoryRateLimit + clientIp / addressKey / userAgent / parseTrustedProxies
auditcreateAuditLogger — fire-and-forget audit-event recorder
totpgenerateSecret, totpAt, verifyTotp, matchTotp, otpauthUrl, generateBackupCodes
sessionscreateSessionStore — DB-backed JWT sessions with revoke, sweep, and last_used_at

Everything is exported from the package root: import { ... } from "@wess/atlas/security".

Headers#

import { withSecurityHeaders } from "@wess/atlas/security"
import { serve } from "@wess/atlas/server"

const fetch = withSecurityHeaders(buildFetch(routes), { dev: process.env.NODE_ENV !== "production" })
Bun.serve({ port: 3000, fetch })

Sets HSTS, X-Content-Type-Options, X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy (camera/mic/geo/cohort off), COOP/CORP same-origin, and a Content-Security-Policy that defaults to 'self'-only with no inline scripts in production. Pass { dev: true } to relax script/connect for HMR, or { csp: "…" } to override entirely. Set { disableCsp: true } for API-only origins.

The wrapper also stashes the Bun socket peer onto the request as req.peerIp so downstream rate-limit / audit code reads the real peer rather than trusting X-Forwarded-For from the client.

Inline downloads#

import { decideInline } from "@wess/atlas/security"

const { contentType, disposition } = decideInline(file.mime, file.name, wantInline)

Forces application/octet-stream; attachment for any MIME outside a narrow safe-list (images, video, audio, PDF, plain text). SVG is excluded — it parses as XML and runs script.

Rate limiting#

import { createDbRateLimit, clientIp, parseTrustedProxies } from "@wess/atlas/security"

const trustedProxies = parseTrustedProxies(process.env.TRUSTED_PROXIES)
const limiter = createDbRateLimit({ db })

const { ok, retryAfterSeconds } = await limiter.check(`signup:ip:${clientIp(req, { trustedProxies })}`, 5, 3600)
if (!ok) return Response.json({ error: "rate_limited", retry_after: retryAfterSeconds }, { status: 429 })

Postgres uses an atomic UPSERT; SQLite uses a transactional read-modify-write inside a single transaction. Schema:

CREATE TABLE rate_limits (
  bucket TEXT PRIMARY KEY,
  count INTEGER NOT NULL,
  window_started_at TIMESTAMPTZ NOT NULL  -- INTEGER unix-seconds on SQLite
);

createMemoryRateLimit({ maxEntries? }) returns the same interface for tests / dev (it also self-sweeps expired buckets opportunistically). Capacity defaults to 10,000. When full of live buckets it evicts the oldest instead of refusing, so nobody can lock every client out by minting keys; the cost is that a flood can reset the oldest counters. Use the DB limiter where that matters.

Both limiters expose sweep(olderThanSeconds) — drop buckets whose window started more than that many seconds ago, returning the removed count. Run it periodically (cron) with a value >= your longest window to keep the table/map from accumulating one row per bucket key forever.

clientIp(req, { trustedProxies }) returns the real client IP. It honors X-Forwarded-For / X-Real-IP only when the request actually arrived from a configured trusted proxy — otherwise the headers are attacker-supplied. Forwarded chains are walked from the nearest hop toward the client; the first untrusted address wins.

addressKey(ip) turns an address into a rate-limit key: IPv6 is grouped by its /64 (2001:db8:1:2::/64), since one host usually controls the whole prefix and could otherwise rotate into a fresh bucket per attempt. IPv4 and IPv4-mapped IPv6 come back as the plain IPv4 address. Key per-address limits with addressKey(clientIp(req, { trustedProxies })).

Audit log#

import { createAuditLogger } from "@wess/atlas/security"

const audit = createAuditLogger({ db })

audit.log({ userId: 42, event: "user.login", ip: clientIp(req), userAgent: userAgent(req) })

Schema:

CREATE TABLE audit_events (
  id          SERIAL PRIMARY KEY,
  user_id     INTEGER NULL,
  event       TEXT NOT NULL,
  metadata    TEXT NULL,            -- JSON
  ip          TEXT NULL,
  user_agent  TEXT NULL,
  created_at  TIMESTAMPTZ DEFAULT NOW()
);

Logging is fire-and-forget — log never throws and never blocks the response.

TOTP / 2FA#

import { generateSecret, otpauthUrl, matchTotp, generateBackupCodes } from "@wess/atlas/security"

const secret = generateSecret()
const provisioning = otpauthUrl({ secret, account: user.email, issuer: "Atlas" })
// render `provisioning` as a QR code

// sign-in: refuse a code from a step already used, then remember this one
const step = matchTotp(secret, code, { window: 1, afterStep: user.totp_last_step ?? undefined })
if (step === null) return Response.json({ error: "invalid_code" }, { status: 401 })
await db.execute(from("users").where((q) => q("id").equals(user.id)).update({ totp_last_step: step }))

const backupCodes = generateBackupCodes()  // store hashed; show plaintext exactly once

Pure node:crypto — no external deps. SHA-1 / 6-digit / 30-second steps, matching every authenticator app in the wild. window: 1 accepts ±30s of clock skew.

Replay. A TOTP code stays valid for its whole window, so on its own verifyTotp accepts the same code twice. matchTotp(secret, code, opts) returns the matched time step (or null); store it per account (e.g. a totp_last_step column) and pass it back as afterStep, which refuses that step and every earlier one. verifyTotp takes afterStep too. Update the stored step with a conditional write (WHERE totp_last_step < step) if two sign-ins can race.

Sessions#

import { createSessionStore } from "@wess/atlas/security"

const sessions = createSessionStore<{ id: number; email: string }>({
  db,
  secret: process.env.JWT_SECRET!,
  ttlSeconds: 86400 * 7,
})

// login
const { token, jti } = await sessions.issue(user, { ip: clientIp(req), userAgent: userAgent(req) })

// every request
const status = await sessions.isActive(jwtPayload.jti)
if (!status.active) return halt(conn, 401)
sessions.touch(jwtPayload.jti)

// logout / logout-everywhere
await sessions.revoke(jti, user.id)
await sessions.revokeAll(user.id, /* keep current */ jti)

// nightly cleanup
await sessions.sweepExpired()

The default JWT payload is the user object minus any field whose name matches /password|secret|token/i (JWT payloads are readable by whoever holds the token). Pass payload: (user) => ({ id: user.id, ... }) to choose claims explicitly.

The JWT stays stateless, but the embedded jti is bound to a row whose revoked_at lets you kill a session server-side. Pair with @wess/atlas/auth#requireAuth plus a sessions check to enforce the revocation.

Schema:

CREATE TABLE sessions (
  id            TEXT PRIMARY KEY,        -- the jti
  user_id       INTEGER NOT NULL,
  ip            TEXT NULL,
  user_agent    TEXT NULL,
  created_at    TIMESTAMPTZ DEFAULT NOW(),
  last_used_at  TIMESTAMPTZ NULL,
  expires_at    TIMESTAMPTZ NOT NULL,
  revoked_at    TIMESTAMPTZ NULL
);

Note: @wess/atlas/auth already exports an in-memory SessionStore for simple flows. The store here is a different (richer) interface — DB-backed, revocable, with audit context. Pick whichever fits.

Dependencies#

  • @wess/atlas/auth — for token.sign (sessions only)
  • @wess/atlas/db — for the rate-limit / audit / session tables
  • node:crypto — for TOTP and randomUUID

Testing#

bun test packages/security/
Type to search guides and package references.