Skip to main content

Security Overview

Psychic gives you the request-handling skeleton; the security posture of your deployed app is the union of the framework defaults and the choices you make on top of them. This page is the single-page index of every security knob the framework exposes — what it defaults to, how to change it, and where to read more.

For framework-internal audit history, see SECURITY_AUDIT_TRACKER.md, SECURITY_CVE_CHECKLIST.md, and docs/THREAT_MODEL.md in the monorepo. For app-developer guidance, this page and the per-topic guides in this section are the source of truth.

What Psychic defends by default​

Out of the box, a generated app gets:

  • SameSite=Strict session cookies. Cookies do not ride along with cross-site requests; classical CSRF is blocked in modern browsers without a token primitive. (A client on another origin of the same site — app.example.com calling api.example.com — still sends them.)
  • AEAD-encrypted cookie payload. Session cookies are AES-GCM encrypted with a per-encryption IV; tampering fails decryption.
  • Weak-key boot rejection. A missing or short cookie encryption key throws at production boot rather than at the first encryption call.
  • Open-redirect protection. Every framework redirect helper funnels through a validator; absolute URLs fail closed unless the destination host appears in your redirectAllowedHosts allowlist.
  • Mass-assignment safety. this.extractParams(Model, [...]) is a type-checked, runtime-intersected primitive; the explicit allowlist is required at every call site and is the framework's canonical mass-assignment defense.
  • Boundary-level input bounding. castParam accepts minLength/maxLength on strings and minimum/maximum on numeric types, checked after type coercion and applied element-wise on array casts; extractParams takes no bound options, so castParam each field that needs one. Use these to bound untrusted input at the controller boundary rather than validating size later in a model or service — see Params — Bounding length and range.
  • Prototype-pollution-safe param parsing. The merge target is Object.create(null); __proto__ / constructor / prototype arriving as own keys cannot pollute Object.prototype.
  • Content-type-agnostic security headers. X-Content-Type-Options: nosniff, Cross-Origin-Resource-Policy, and HSTS in production are defaults. Document-scoped headers (CSP, XFO, COOP, Permissions-Policy, Referrer-Policy) are deliberately not shipped — Psychic emits JSON, not HTML, so those headers are no-ops on framework responses. See Output Encoding & i18n for the boundary.
  • CORS off by default. @koa/cors is not mounted unless you call psy.set('cors', ...). Apps that need cross-origin opt in.
  • WebSocket origin allowlist + first-connection auth. New scaffolds re-enforce the CORS origin allowlist in socket.io's allowRequest hook, which covers every transport, and sign each socket in from a ws:connect hook that disconnects it when the boilerplate resolveWebsocketUser returns null.
  • Run-time-safe shell spawn. Framework sspawn retains shell semantics for legitimate dev-CLI glue; argv-form sspawnArgs is the secure default for new code.
  • Body limits. @koa/bodyparser defaults (1 MB JSON, 56 KB form) are passed through as-is and tunable via psy.set('json', { jsonLimit: '...' }). These limits are the app's own defense against oversized-payload memory DoS, whether or not an edge layer also caps bodies.
  • Failed-job retention. BullMQ's failed-set is the dead-letter queue; the boilerplate ships removeOnFail: 20000 + attempts: 20 + exponential backoff.

Application-layer responsibilities​

Things the framework cannot do for you, listed here so you do not assume any of them are framework-handled:

  • Authentication / session establishment. The framework provides the encrypted-session primitive; verifying credentials, issuing the session, and resolving the current user are app code (resolveCurrentUser, resolveWebsocketUser).
  • Per-action authorization. No framework hook stops a logged-in user from acting on resources they should not see; controller code checks ownership.
  • Password hashing. Use bcrypt (cost ≥ 12) or argon2 (default argon2id); store the hash in an encrypted column via @Encrypted or as a plain text column with a long, random salt the library handles for you. Compare with the library's verify function, never with ===.
  • JWT verification with pinned algorithms. If you adopt JWT bearer auth, always pass algorithms: ['RS256'] (or your specific algorithm) to jwt.verify. Default-allow-any-alg is the classic JWT confusion vulnerability.
  • Reset / verification token generation. Use crypto.randomBytes(32).toString('base64url') for tokens; compare with crypto.timingSafeEqual after decoding.
  • Rate limiting. Edge tier first (WAF / CDN / nginx); app tier with rate-limiter-flexible for per-route / per-user / cost-based limits. See Rate Limiting.
  • OpenAPI exposure gating. OpenapiAppRenderer.sync() writes a build artifact; if you serve the resulting JSON, you own the auth decision on that route.
  • Database TLS. The boilerplate ships ssl: { rejectUnauthorized: true } (verified TLS via Node's system CA store), which works out of the box with managed providers that present a public-CA-signed certificate (Supabase, Neon, Render, Azure Database for PostgreSQL Flexible Server). For providers that present a private-CA certificate (AWS RDS, GCP Cloud SQL), add a ca bundle: ssl: { rejectUnauthorized: true, ca: readFileSync('/path/to/ca.pem') }. For providers that present a self-signed certificate (Heroku Hobby, some local Docker images), set ssl: { rejectUnauthorized: false } — encrypted but not authenticated.
  • Redis credentials & TLS. See Workers & Redis; tls: {} in ioredis enables verified TLS by default — never set rejectUnauthorized: false in production.

Per-topic guides​

Each link below goes deeper on one piece of the security posture.

  • Output Encoding & i18n — why server-side HTML escaping is wrong-layer for a JSON API; the structured-translations pattern for rich text.
  • Rate Limiting — edge-tier first, app-tier with rate-limiter-flexible; the io.engine.use() hook for socket.io.
  • Workers & Redis — what tls: {} actually does; why BullMQ's failed-set is the DLQ; optional cert pinning and dedicated-DLQ recipes.
  • Supply Chain & Audit — how to read npm audit output for a Psychic app; runtime vs dev/build-time triage; per-PM override recipes.

The framework knobs (one-line each)​

ConcernKnobDefaultSee
Session cookie lifetimepsy.set('cookie', { maxAge })14 days in the scaffold's conf/app.ts (31 when unset); SameSite=Strict, httpOnly, Secure in productionSession config below
Cookie encryption keypsy.set('encryption', { cookies: { current, legacy } })none — boot throws in productionEncryption rotation below
Redirect allowlistpsy.set('redirectAllowedHosts', [...])empty (relative redirects only)Redirect allowlist below
Mass assignmentthis.extractParams(Model, [allowed])emitted by the generatorMass assignment below
LIKE / ILIKE escapingops.like.escape(value)manual opt-inLIKE / ILIKE below
Default response headerspsy.set('defaultResponseHeaders', {...})content-type-agnostic baselineHeaders below
CORSpsy.set('cors', {...})not mountedCORS below
Body sizepsy.set('json', { jsonLimit, formLimit, ... })1mb / 56kbBody limits below
File uploads(app concern)n/aFile Uploads tutorial
WebSocket authresolveWebsocketUser in a ws:connect hook + allowRequest (scaffold)scaffold rejects unauth, allowlist enforcedWebSocket auth below
Worker connectionsdefaultQueueConnection / defaultWorkerConnectionTLS via tls: {} in production boilerplateWorkers & Redis
Logger redactionrequestLogger headerBlocklist / bodyBlocklistscaffold leaves out authorization / cookie / referer headers and masks password / token / secret-like namesLogging below
Unhandled-error loggingpsy.on('server:error', handler)logs via the registered loggerLogging below
path-to-regexp pinoverrides / resolutions / pnpm.overridesscaffolded with >=8.4.0 pinSupply Chain & Audit

Detail sections​

psy.set('cookie', {
maxAge: { days: 14 }, // what the scaffold sets; 31 days when unset
})

maxAge is the app-wide cookie option, and startSession always uses it. Every cookie Psychic sets is httpOnly, SameSite=Strict, and Secure in production unless the options passed to this.setCookie() override them for that one cookie; startSession always uses Strict. SameSite=Strict is the framework's CSRF defense. Relaxing a cookie to 'lax' re-introduces the classical CSRF surface and is rarely the right call for a JSON API — link-click navigations to a JSON endpoint do nothing useful. See Cookies — Sessions for what maxAge does and doesn't revoke.

Encryption key rotation​

Cookies are AEAD-encrypted via Dream's Encrypt. The rotation primitive is a current / legacy pair:

psy.set('encryption', {
cookies: {
current: { algorithm: 'aes-256-gcm', key: AppEnv.string('APP_ENCRYPTION_KEY') },
legacy: { algorithm: 'aes-256-gcm', key: AppEnv.string('LEGACY_APP_ENCRYPTION_KEY') },
},
})

Rotation workflow:

  1. Generate a new key: Encrypt.generateKey('aes-256-gcm').
  2. Set current to the new key, legacy to the previously-current key. Deploy.
  3. Wait for maxAge to elapse so all in-the-wild cookies have rolled.
  4. Drop legacy. Deploy.

Two-key rotation is sufficient for any sensible cookie TTL. The framework throws on a missing or undersized key in production at boot — use Encrypt.generateKey to produce keys, never hand-typed strings.

A cookie no configured key opens (forged, garbled, or issued under a dropped key) reads as absent: getCookie returns null and logs nothing. Koa middleware that runs outside the controller layer (e.g. gating a Bull Board dashboard) has no this.getCookie(). Read the cookie with new PsychicSession(ctx).getCookie(name), the class this.getCookie() calls; it decrypts with the same keys and treats an undecryptable cookie the same way. See Cookies — Decrypting cookies outside a controller, and Decrypting a value yourself for a bearer token the app decrypts with Encrypt.

Redirect allowlist​

psy.set('redirectAllowedHosts', ['app.example.com', 'login.example.com'])

Every framework-level redirect (controller.redirect(path), the variants returning specific status codes) funnels through isSafeRedirectTarget. Relative paths always succeed; absolute URLs require a host match. Scheme-relative URLs (//evil.com), backslash variants, non-http schemes, userinfo segments, CRLF, and control characters all fail closed. Twenty-five regression specs in psychic/spec/unit/security/redirect-helper.security.spec.ts cover the cases.

Mass assignment​

import User from '#models/User.js'

class UsersController extends AuthedController {
async update() {
const allowed = this.extractParams(User, ['email', 'name', 'avatarUrl'])
await this.user.update(allowed)
this.ok(this.user)
}
}

Generators emit this scaffold for every create / update action in every namespace (including admin). The same shared paramSafeColumns const at the top of the controller is reused at each call site.

The allowed array is typed as readonly DreamParamSafeColumnNames<...>[], so passing a protected column produces a compile error. The runtime intersection against paramSafeColumnsOrFallback() strips anything that slipped past TypeScript.

LIKE / ILIKE with user input​

import { ops } from '@rvoh/dream'

const pattern = `%${ops.like.escape(userInput)}%`
await User.where({ email: ops.ilike(pattern) }).all()

LIKE / ILIKE values are bound parameters in Kysely, so there is no SQL injection. The semantic-correctness concern is % and _ in the user input acting as wildcards. ops.like.escape escapes those (and the \ escape character itself) so the pattern matches the literal characters the user typed.

ops.match (regex) does not have an escape helper — regex DoS via attacker-supplied patterns is up to the caller to bound (length cap, timeout, or pre-validation).

Default response headers​

psy.set('defaultResponseHeaders', {
'Content-Security-Policy': "default-src 'self'", // only meaningful if you serve HTML
'Permissions-Policy': 'geolocation=()', // ditto
})

The framework's content-type-agnostic baseline (X-Content-Type-Options: nosniff, Cross-Origin-Resource-Policy, HSTS in production) is always shipped. Document-scoped headers are not shipped by default because they are no-ops on JSON. Apps that emit HTML opt in via the example above.

CORS​

psy.set('cors', {
origin: ['https://app.example.com'],
credentials: true,
})

@koa/cors is not mounted unless you set this. The framework deliberately declines to mount a wrapper the developer has not opted into — this is the principled fix for @koa/cors's upstream origin: '*' default. Once you opt in, the developer-passed options reach @koa/cors unchanged; refer to that package's docs for option semantics.

Body size limits​

psy.set('json', {
jsonLimit: '256kb', // default '1mb'
formLimit: '32kb', // default '56kb'
enableTypes: ['json'],
})

Spread-merged into the existing options, so partial configs do not unset the upstream defaults. koa-bodyparser enforces these at parse time; the framework does not re-cap.

File uploads​

The framework does not bundle multipart parsing. The 2026-default pattern is presigned PUT to S3 / R2 / GCS — bytes never reach the app server, so traversal is structurally impossible. See the File Uploads tutorial for the recommended flow plus a fallback recipe for local handling.

WebSocket auth & origin​

// conf/initializers/websockets.ts (boilerplate)
const allowedOrigins = allowedCorsOrigins()
wsApp.set('socketio', {
cors: { credentials: true, origin: allowedOrigins },
allowRequest: (req, callback) => {
const origin = req.headers.origin
if (origin !== undefined && allowedOrigins.includes(origin)) callback(null, true)
else callback('origin not allowed', false)
},
})

wsApp.on('ws:connect', async socket => {
const user = await resolveWebsocketUser(socket)
if (!user) {
socket.disconnect(true)
return
}
await Ws.register(socket, user)
})

socket.io's built-in cors.origin only constrains long-polling — native WebSocket upgrades bypass CORS — so the scaffold re-enforces the allowlist in allowRequest, which runs before every handshake on every transport. Keep allowRequest to checks on the raw HTTP request: the client's auth token isn't available there yet, and a throw there isn't contained. Sign sockets in from the ws:connect hook instead, where the framework contains a throw (a database or Redis blip, the scaffold's placeholder throw) to the one socket, logs it, and reports it to ws:error. The scaffolded resolveWebsocketUser reads the token from socket.handshake.auth in test, and throws in non-test envs until you wire it to your production auth.

Logging & error disclosure​

// scaffold ships in api/src/conf/app.ts (abridged)
psy.set('logger', winstonLogger(apiRoot))

psy.on('server:init:after-middleware', psychicServer => {
psychicServer.koaApp.use(
requestLogger({
transports: [new winston.transports.Console()],
format: winston.format.combine(winston.format.json()),
headerBlocklist: ['authorization', 'content-length', 'connection', 'cookie', 'referer', /* … */],
bodyBlocklist: ['password', 'token', 'authentication', 'authorization', 'secret'],
ignoredRoutes: ['/health_check'],
}),
)
})

psy.on('server:error', (err, ctx) => {
// custom redaction or routing for unhandled errors
// — pg errors carry `.parameters`; library errors may attach the request.
// this is the seam to scrub before the logger sees the value.
})

PsychicApp.log / logWithLevel are a logger facade; the framework does not redact what developers explicitly log. Request logging redaction is at the app layer, in the scaffold's conf/system/requestLogger.ts, which writes one JSON line per request through its own Winston instance:

  • headerBlocklist — request headers left out of the log line, matched on the whole name, ignoring case.
  • bodyBlocklist — names whose values are logged as [FILTERED], key kept: any body key at any depth (arrays included), query-string key, header name, or route param (the :token segment of /password-resets/:token) that contains a listed name, ignoring case. The scaffold's list already covers accessToken and x-auth-token. Extend it when you add new sensitive fields (api_key, api-key for an x-api-key header, ssn, creditCard), or they land in production logs.
  • ignoredRoutes — request paths skipped entirely.

The path of a request no route matched is logged as sent, so put an emailed token in the URL fragment (/reset-password#token=…), which browsers never send, and have the page post it as token in the body, where bodyBlocklist masks it. The server:error hook is the seam for customizing unhandled-error logging — useful when a pg error walks into .parameters containing user data, or when a third-party library attaches the original request to its error.

Cadence​

  • Every release. Re-run pnpm audit and triage per the Supply Chain guide.
  • Every quarter. Review extractParams allowlists across controllers; nothing forces them to stay narrow over time.
  • Every key rotation. Generate a new cookie encryption key with Encrypt.generateKey; rotate via the current / legacy pair.
  • Whenever you touch auth or session logic. Re-read this page; the defaults are not a substitute for reading the boundaries you are crossing.