@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 / userAgent / parseTrustedProxies
auditcreateAuditLogger — fire-and-forget audit-event recorder
totpgenerateSecret, totpAt, verifyTotp, otpauthUrl, generateBackupCodes
sessionscreateSessionStore — DB-backed JWT sessions with revoke, sweep, and last_used_at

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

Headers#

import { withSecurityHeaders } from "@atlas/security"
import { serve } from "@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 "@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 "@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() returns the same interface for tests / dev (it also self-sweeps expired buckets opportunistically).

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.

Audit log#

import { createAuditLogger } from "@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, verifyTotp, generateBackupCodes } from "@atlas/security"

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

if (!verifyTotp(secret, code, { window: 1 })) return Response.json({ error: "invalid_code" }, { status: 401 })

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.

Sessions#

import { createSessionStore } from "@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 JWT stays stateless, but the embedded jti is bound to a row whose revoked_at lets you kill a session server-side. Pair with @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: @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#

  • @atlas/auth — for token.sign (sessions only)
  • @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.