cookies
Since most modern applications will require some form of cookie usage, Psychic provides direct cookie support out of the box. Configuration for this can be set in conf/app.ts, both in the cookie config option (documented here), and in the encryption config option (documented here).
Psychic leverages symmetric encryption whenever creating or reading cookies on your behalf, utilizing the configuration you provide in conf/app.ts.
Setting cookies
Since setting cookies is something that is request-driven, it can only be done within your controllers. Helpful methods are attached to your controller to utilize your cookie configurations, like so:
// app/controllers/PreferencesController.ts
public async update() {
...
this.setCookie('theme', 'dark', { maxAge: { days: 365 } })
}
The value is a string. Cookies attached using this process are httpOnly, SameSite=Strict, and, in production, Secure; the options passed to this.setCookie() override any of these for that one cookie. The cookie lasts for the maxAge passed, else until the expires passed, else for the app's cookie maxAge from conf/app.ts.
this.setCookie() also always encrypts the cookie value — there is no option to disable this. That's the correct behavior for cookies your app both writes and reads back (session data, preferences), since this.getCookie() automatically decrypts. If you need to set a cookie for a different service to consume, see Setting cookies for external services below.
Retrieving cookies
To retrieve cookies that have already been set, you can leverage the getCookie method provided on Psychic controllers, like so:
// app/controllers/PreferencesController.ts
public async show() {
...
const theme = this.getCookie<string>('theme')
}
getCookie returns the string that was set, or null when the request carries no such cookie.
Sessions
// Start session (sets an encrypted cookie)
this.startSession(user)
// End session
this.endSession()
startSession creates no server-side session record — the encrypted session cookie, whose payload is { id, modelKey } (the user's primary key and model name), is the entire credential. It takes no options and uses the app's default maxAge, which the generated conf/app.ts sets to 14 days (31 days when the app sets none). For a shorter-lived session, lower it there (this changes it for every cookie the app sets, not just sessions):
psy.set('cookie', { maxAge: { hours: 12 } })
endSession only clears the cookie on the client, and maxAge only tells the browser when to discard its copy. A cookie value captured elsewhere (a compromised device, a logged request) stays valid for as long as the key that encrypted it is configured as current or legacy, regardless of "logout." There is no per-session or per-user revocation. The only framework lever is replacing the cookie encryption key without keeping the old one as legacy (see Cookie encryption below), which invalidates every user's session, not just one. If you need to revoke a single compromised session or a single user's sessions, track that state yourself rather than assuming endSession accomplishes it server-side.
Cookies Psychic sets default to SameSite=Strict: the browser never sends them on a cross-site request, including a link-click navigation from another site, which blocks classical CSRF without needing a CSRF token. A client on another origin of the same site (app.example.com calling api.example.com) still sends them. startSession always uses Strict; this.setCookie()'s options can override it for a single cookie.
Cookie encryption
Cookie encryption requires an encryption key configured in conf/app.ts:
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'),
},
},
})
The optional legacy key enables seamless key rotation: getCookie() first tries the current key, and if decryption fails, retries with legacy. A cookie neither key opens (forged, garbled, or issued under a key that is no longer configured) reads as null, the same as a missing cookie, and nothing is logged. A DecryptionParseError, or a configured key of the wrong length, still throws: those point at an app or configuration bug, not at the value sent.
Rotating the cookie encryption key
- Generate a new key (
pnpm psy g:encryption-keyorEncrypt.generateKey('aes-256-gcm')). - Set
currentto the new key,legacyto the previously-current key. Deploy. - Wait for
maxAgeto elapse so all in-the-wild cookies have rolled. - Drop
legacy. Deploy.
Two keys is sufficient for any sensible cookie TTL.
Generating keys
Two ways to produce a key, for cookie encryption or any other Encrypt use case (e.g. encrypted columns):
# CLI generator — primary; defaults to aes-256-gcm.
# --algorithm accepts aes-256-gcm | aes-192-gcm | aes-128-gcm.
pnpm psy g:encryption-key
pnpm psy g:encryption-key --algorithm aes-128-gcm
// Programmatic equivalent — useful in tests, fixtures, or one-off scripts.
import { Encrypt } from '@rvoh/dream/utils'
const key = Encrypt.generateKey('aes-256-gcm')
Never hand-type encryption keys. Use one of these helpers and feed the result through your secrets manager (AWS Secrets Manager, SSM Parameter Store, Vault, etc.) into AppEnv.
Decrypting cookies outside a controller
Koa middleware that runs outside Psychic's controller layer (e.g., protecting a Bull Board dashboard) has no this.getCookie(). Read the cookie through PsychicSession, the class this.getCookie() calls, which decrypts with the same keys:
import { PsychicApp, PsychicSession } from '@rvoh/psychic'
import type Koa from 'koa'
// the session cookie's { id, modelKey }, or null for a logged-out request
export default function readSessionCookie(ctx: Koa.Context): { id: string; modelKey: string } | null {
const payload = new PsychicSession(ctx).getCookie(PsychicApp.getOrFail().sessionCookieName) as string | null
// startSession stores the JSON string of { id, modelKey }
return payload ? (JSON.parse(payload) as { id: string; modelKey: string }) : null
}
PsychicSession#getCookie reads a cookie no configured key opens — forged, or from a retired key — as absent, exactly as this.getCookie() does: it returns null and logs nothing.
Decrypting a value yourself
When the app decrypts user-supplied ciphertext with Encrypt.decrypt itself — a bearer token from the Authorization header, for example, as the generated resolveCurrentUser helper does — decrypt throws on failure rather than returning null. The three error classes export from @rvoh/dream/errors (not the @rvoh/dream root), and Encrypt from @rvoh/dream/utils:
decryptreturns what was encrypted,JSON.parsed once.setCookieencrypts a string, so a Psychic cookie decrypts to a string; for the session cookie, that string is the JSON of{ id, modelKey }.null/undefinedciphertext returnsnull(no throw). A missing key throwsMissingEncryptionKey.DecryptionError— the cipher op / auth-tag / payload shape was invalid: the ciphertext was tampered with, corrupted, or encrypted with a different key.DecryptionParseError— the cipher and auth tag validated (correct key, untampered ciphertext), butJSON.parseof the decrypted plaintext threw. This is strictly a format/contract mismatch, never tampering and never a wrong key — with a shared key from another service, it usually means their payload doesn't match the JSON-wrapped shapeEncryptexpects.- Three-argument form
decrypt(ciphertext, currentOpts, legacyOpts)tries the current key and, onDecryptionErroronly, falls back to the legacy key. If both fail withDecryptionError, it throwsDecryptionRotationErrorcarrying.currentKeyErrorand.legacyKeyError. ADecryptionParseErrorfrom the current key is not retried against the legacy key — the cipher already matched, so it propagates directly.
For user-supplied ciphertext, catch exactly DecryptionError and DecryptionRotationError, treat the request as unauthenticated, and rethrow anything else. DecryptionRotationError does not extend DecryptionError, so catch both:
import { DecryptionError, DecryptionRotationError } from '@rvoh/dream/errors'
import { Encrypt } from '@rvoh/dream/utils'
let payload: { userId?: string } | null
try {
payload = Encrypt.decrypt<{ userId?: string }>(token, {
algorithm: 'aes-256-gcm',
key: AppEnv.string('APP_ENCRYPTION_KEY'),
})
} catch (error) {
// no configured key opens this token: signed out, as getCookie treats a cookie it can't open
if (error instanceof DecryptionError || error instanceof DecryptionRotationError) return null
throw error
}
The class says only whether a legacy key was passed. With one, every value no key opens is a DecryptionRotationError; without one, every such value is a DecryptionError, including one encrypted under a key that was dropped too early. DecryptionParseError and MissingEncryptionKey propagate (a 500): the first is an app bug and the second a configuration error.
This applies to user-controlled ciphertext only — a session cookie, a token from a header. System-controlled ciphertext, like an @Encrypted model column, should propagate any decryption error untouched: keys and ciphertext there are both system-controlled, so a decryption failure is a corrupted column or a broken rotation, not untrusted input, and it should fail loudly rather than be caught and nulled.
Setting cookies for external services (bypassing encryption)
When setting cookies intended for a different service to consume — e.g., CloudFront signed cookies for private content access — this.setCookie() won't work, because the consuming service can't decrypt Psychic's encrypted values. Use ctx.append('Set-Cookie', ...) directly on the Koa context instead:
// In a controller action:
this.ctx.append(
'Set-Cookie',
`CloudFront-Key-Pair-Id=${keyPairId}; Path=/; Domain=.example.com; SameSite=Lax; Secure`
)
this.ctx.append(
'Set-Cookie',
`CloudFront-Signature=${signature}; Path=/; Domain=.example.com; SameSite=Lax; Secure`
)
Use ctx.append (not ctx.set) so multiple Set-Cookie headers don't overwrite each other. This bypasses both Psychic's encryption and Koa's cookie signing. The Koa context is accessible in any controller action via this.ctx, and can be passed to service methods that need it.
Proxy configuration for secure cookies
Psychic marks the cookies it sets Secure in production. When the app runs behind a reverse proxy that terminates TLS (a load balancer, Cloud Run, Cloudflare Tunnel, a dev tunnel like ngrok) and forwards plain HTTP, the Koa server rejects those cookies with "Cannot send secure cookie over unencrypted connection", so startSession and setCookie answer 500. Setting app.proxy = true in conf/app.ts makes Koa trust the proxy's X-Forwarded-Proto header:
psy.on('server:init:after-middleware', psychicServer => {
psychicServer.koaApp.proxy = true
})
app.proxy trusts X-Forwarded-Proto from any upstream, so reserve it for a dev tunnel, or for a proxy that forwards plain HTTP to a container only it can reach. Otherwise, re-encrypt traffic between the proxy and the application and configure TLS on the application itself (see TLS behind a reverse proxy) — frameworks like HIPAA that mandate encryption in transit require it.