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.
  • 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; this.extractImplicitParams(Model) is its model-driven counterpart for namespaces where paramSafeColumns is the canonical allowlist.
  • 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 wire allowRequestForOrigins(allowedCorsOrigins()) across all socket.io transports; the boilerplate resolveWebsocketUser rejects unauth handshakes.
  • 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: '...' }).
  • 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. For verified Postgres TLS, set ssl directly on the credential — e.g. ssl: { rejectUnauthorized: true, ca: readFileSync('/path/to/ca.pem') } for a private PKI, or ssl: true to use Node's default verification against the system CA. The legacy useSsl: true shorthand (without an ssl field) is opportunistic TLS only — encrypted but not authenticated — and exists for back-compat with managed Postgres providers that ship self-signed CA chains. New code should set ssl explicitly.
  • 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 scopepsy.set('cookie', { sameSite, maxAge, domain, path })sameSite: 'strict', 31-day maxAgeSession 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]) / this.extractImplicitParams(Model)emitted by the generatorMass assignment below
LIKE / ILIKE escapingescapeLikePattern(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 (scaffold) + allowRequestForOriginsscaffold rejects unauth, allowlist enforcedWebSocket auth below
Worker connectionsdefaultQueueConnection / defaultWorkerConnectionTLS via tls: {} in production boilerplateWorkers & Redis
Logger redactionpsy.set('logger', logger) + requestLogger headerBlocklist / bodyBlocklistscaffold ships authorization / cookie / password / token redactionLogging 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', {
sameSite: 'strict', // do not relax to 'lax' without re-running the threat model
maxAge: { days: 14 }, // shorter than the default 31 if your auth flow allows
domain: 'app.example.com', // pin to your app's host
path: '/',
})

SameSite=Strict is the framework's CSRF defense. Relaxing it 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.

Encryption key rotation

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

psy.set('encryption', {
cookies: {
current: AppEnv.string('COOKIE_KEY_CURRENT'),
legacy: AppEnv.string('COOKIE_KEY_LEGACY', { optional: true }),
},
})

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.

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'

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 non-admin namespaces. Admin-namespace generators emit this.extractImplicitParams(Model) because the model-level paramSafeColumns declaration is the canonical allowlist. Override at scaffold time with --with-extract-params / --with-extract-implicit-params.

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 { escapeLikePattern } from '@rvoh/dream'

const pattern = `%${escapeLikePattern(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. escapeLikePattern 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

// boilerplate websockets initializer
io.engine.use(allowRequestForOrigins(allowedCorsOrigins()))

io.on('connection', async (socket) => {
const user = await resolveWebsocketUser(socket)
if (!user) { socket.disconnect(true); return }
await Ws.register(socket, user)
})

allowRequestForOrigins wraps socket.io's allowRequest hook so the origin allowlist applies across all transports (long-polling and native WebSocket). socket.io's built-in cors.origin only covers long-polling — the native-WS-bypasses-CORS gap is the reason allowRequestForOrigins exists. The scaffolded resolveWebsocketUser uses Encrypt.decrypt against socket.handshake.auth.token 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
psy.set('logger', winstonLogger)
psy.use(requestLogger({
headerBlocklist: ['authorization', 'cookie', 'x-api-key'],
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 (boilerplate ships sensible defaults). 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 / extractImplicitParams 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.