Security model
Devora.js treats security as a default, not an opt-in plugin — every app gets a set of protections whether or not its app.config.ts declares a security block at all.
CSP, HSTS, and frame options
Every response carries these headers by default:
CSP
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self' — locks scripts, connections, and images to same-origin, blocking injected, inline, or eval'd script execution. style-src additionally allows 'unsafe-inline' as a deliberate, documented tradeoff: React's style={{...}} prop compiles to an inline style attribute, which a strict default would otherwise silently break for every app.
X-Frame-Options
X-Frame-Options: DENY — blocks the app from being framed by another site.
HSTS
Strict-Transport-Security: max-age=63072000; includeSubDomains — a two-year HSTS policy. This header is a no-op over plain HTTP (browsers only honor it on responses received over HTTPS), so it's safe to always send, including in dev.
Each of these is overridable per app (security.csp, security.frameOptions, security.hsts in app.config.ts) — the point is that you have to opt out, not opt in. security.noindex: true adds X-Robots-Tag: noindex to every response of an app search engines shouldn't index (an admin panel); on Vercel and Netlify it's also set on statically served files.
Sessions
Changed in @devorajs/core 0.3.0 — breaking
Before 0.3.0 a session was its cookie: your session data, signed with an HMAC and stored in the browser. That design couldn't revoke a session short of rotating the secret, and API/mobile clients had to build their own Bearer-token scheme alongside it. Since 0.3.0:
- Production needs a session store. A production server for any app with
auth: "shared"or"isolated"refuses to start untilshared.sessions.storeis set indevora.config.ts(see below). In dev an in-memory store is used, with a warning. - Upgrading logs everyone out once. Old signed cookies are not valid session IDs; they're treated as dead and cleared, so every existing user signs in again.
The session system is not an identity provider — it doesn't check passwords. Your code decides who someone is, then calls ctx.setSession(data); the framework makes that result persist across requests, and makes it revocable.
- A session is a random, opaque ID. 256 random bits — not a JWT, not a signed cookie value. The client holds only the ID. The record (whatever you passed to
setSession, plus two expiry times) lives server-side, in a session store your project supplies. - One lookup, two transports. Browsers carry the ID in an
HttpOnlycookie; mobile and API clients sendAuthorization: Bearer <sessionId>. Both go through the same lookup, and the Bearer header is checked first. A request that sends a Bearer header is never authenticated by a cookie, even if its token turns out to be invalid. - Active → idle → dead. A new session is active for
activeSeconds(default 1 day), then idle foridleSecondsmore (default 14 days). Using an idle session silently extends both windows, keeping the same ID — so clients never need a refresh-token flow. After that it's dead and the user signs in again. - Real revocation.
ctx.revokeSession()deletes the record, so the session stops working on both transports on the very next request.ctx.revokeSession(otherId)revokes a different session you recorded (e.g. "sign out my other devices" after a password change).clearSession()still exists as an alias, so existing logout code revokes server-side without changes. - Leaked store, no usable tokens. The store is keyed by an HMAC of the ID (keyed with the session secret), never the raw ID. The app's auth scope is part of that HMAC too, so a session from a
sharedapp is simply not found by anisolatedapp — on the cookie and on the Bearer transport — even if the two share a secret and a store.
Configuring the store
"memory" is fine for dev and a single long-lived devora start process. For Vercel/Netlify or more than one server, point store at a module whose default export is a store backed by your own database — it's three methods:
// devora.config.ts
import { defineProject } from "@devorajs/core/config";
export default defineProject({
apps: [{ name: "web", dir: "apps/web", domain: "web.example.com" }],
shared: {
core: "packages/core",
backend: "packages/backend",
auth: "shared",
sessions: {
store: "packages/backend/sessionStore.ts", // or "memory"
activeSeconds: 60 * 60 * 24, // optional, default 1 day
idleSeconds: 60 * 60 * 24 * 14, // optional, default 14 days
},
},
});// packages/backend/sessionStore.ts — a working example on SQLite via node:sqlite
// (built into Node 22.5+). Swap the three queries for your own database/driver.
import { DatabaseSync } from "node:sqlite";
import { defineSessionStore } from "@devorajs/core";
const db = new DatabaseSync(process.env.SESSION_DB_PATH ?? "sessions.db");
db.exec(`CREATE TABLE IF NOT EXISTS sessions (
key TEXT PRIMARY KEY, data TEXT NOT NULL,
active_expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`);
const getRow = db.prepare("SELECT data, active_expires_at, expires_at FROM sessions WHERE key = ?");
const upsert = db.prepare(
"INSERT INTO sessions (key, data, active_expires_at, expires_at) VALUES (?, ?, ?, ?) " +
"ON CONFLICT(key) DO UPDATE SET data = excluded.data, " +
"active_expires_at = excluded.active_expires_at, expires_at = excluded.expires_at"
);
const remove = db.prepare("DELETE FROM sessions WHERE key = ?");
export default defineSessionStore({
get(key) {
const row = getRow.get(key) as { data: string; active_expires_at: number; expires_at: number } | undefined;
return row
? { data: JSON.parse(row.data), activeExpiresAt: row.active_expires_at, expiresAt: row.expires_at }
: undefined;
},
set(key, record) {
upsert.run(key, JSON.stringify(record.data), record.activeExpiresAt, record.expiresAt);
},
delete(key) {
remove.run(key);
},
});The framework deletes an expired record when someone presents it. Records nobody ever presents again stay until you clean them up — a periodic DELETE FROM sessions WHERE expires_at < ? with the current time is enough.
Browser and API/mobile login
Same primitive, different transport. A browser login sets the cookie (the default); an API/mobile login asks for transport: "bearer", which sets no cookie and returns the ID for your response body:
// apps/web/endpoints/session.ts — token login and logout for mobile/API clients
import { apiRoute, HttpError } from "@devorajs/core";
export const methods = ["POST", "DELETE"];
export const handler = apiRoute(async (req, ctx) => {
if (req.method === "DELETE") {
ctx.requireAuth();
await ctx.revokeSession(); // dead on every transport from the next request on
return { status: 204 };
}
const { username } = JSON.parse(req.body.toString("utf-8") || "{}");
if (typeof username !== "string" || !username) throw new HttpError(400, "username required");
// Check the password/OTP against your own database here, before this line.
const token = await ctx.setSession({ username }, { transport: "bearer" });
return {
status: 201,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }), // the client sends: Authorization: Bearer <token>
};
});Anywhere else in the app, ctx.requireAuth() and ctx.session accept that token or the browser's cookie with no extra code, and ctx.sessionTransport tells you which one it was. On the client, keep the token in secure storage (the iOS Keychain / Android Keystore, e.g. via expo-secure-store), not plain app storage.
CSRF
CSRF protection is an explicit call, not framework-injected middleware — a route that changes state calls ctx.verifyCsrf(...) itself, the same way it calls requireAuth(). It's a double-submit-cookie pattern, with one difference from the textbook version: the token is embedded server-side into the rendered form rather than read from the cookie by client JS, so the CSRF cookie stays HttpOnly. A page gets its token as the csrfToken prop; a <form> posts it via CsrfField, and a same-origin API call sends it on the x-devora-csrf header.
It applies to cookie-authenticated requests only. A cross-site page can make a browser send its cookies automatically, but it can't make it attach an Authorization header — so for a request that successfully authenticated with a Bearer token, verifyCsrf() passes without a token. Cookie-authenticated and unauthenticated requests (a login form, say) are always checked. A failed check throws a 403; a failed requireAuth() throws a 401. On a page that status is kept and the app's error.tsx renders; in an API route it's a JSON { message }.
Bearer-only APIs: csrf: false in an app's app.config.ts stops the devora_csrf cookie from being issued at all (a mobile client's login call used to receive one). verifyCsrf() then passes only for Bearer-authenticated requests and rejects everything else with a 403 — it never silently passes.
// apps/web/endpoints/transfer.ts — one route, safe for both browsers and mobile clients
import { apiRoute, CSRF_HEADER_NAME } from "@devorajs/core";
export const methods = ["POST"];
export const handler = apiRoute((req, ctx) => {
ctx.requireAuth();
const token = req.headers[CSRF_HEADER_NAME];
// Enforced for a cookie session; skipped for a Bearer session.
ctx.verifyCsrf(typeof token === "string" ? token : "");
return { status: 200, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ok: true }) };
});Cookies, proxies and subdomains
Session and CSRF cookies are always HttpOnly. Their other attributes apply in production only (devora dev never sets Secure or Domain, so plain HTTP keeps working locally) and are set in shared.sessions.cookie in devora.config.ts, with per-app overrides in app.config.ts sessions.cookie:
| Setting | Default | Does |
|---|---|---|
secure | true | true: always Secure. Browsers drop a Secure cookie over plain HTTP, so a login served over HTTP won't stick (the server logs a warning). "auto": only on HTTPS requests — a TLS connection, or X-Forwarded-Proto: https with trustProxy. false: never, for a deliberately plain-HTTP deployment |
sameSite | "lax" | "lax", "strict" or "none" (which requires secure: true) |
domain | unset (host-only) | "example.com" shares one login across subdomains. Session cookie only (the CSRF cookie stays host-only); every shared app must be served from that domain or a subdomain, checked at build and dev start |
trustProxy (in each app's app.config.ts) tells the app it sits behind a proxy: true trusts one hop, a number trusts that many (a CDN in front of nginx is 2). It's what makes secure: "auto", ctx.url and ctx.ip use the forwarded scheme, host and client IP. Default false for devora start; true inside the generated Vercel/Netlify functions, which only the platform's edge can reach. Only enable it when the app can't be reached except through that proxy, or a client could send the headers itself. Session lifetimes (activeSeconds/idleSeconds) can also be set per app for auth: "isolated" apps. Settings that can't work — a domain that doesn't cover the app, session settings on an auth: "none" app — fail the build and the dev server with a message.
What production requires
For an app with auth: "shared" or "isolated", a production server refuses to start without both a session secret and a session store — it throws rather than falling back to an insecure default or to in-memory sessions. An app with auth: "none" needs neither.
| Setting | Required when | Set to |
|---|---|---|
shared.sessions.store in devora.config.ts | Any shared/isolated-auth app, in production | A path to your store module, or "memory" for a single long-lived server (never serverless) |
DEVORA_SESSION_SECRET | A shared-auth app, in production | A random value — openssl rand -base64 32 works well. Keys the HMAC that turns session IDs into store keys; rotating it signs everyone out |
DEVORA_SESSION_SECRET_<APPNAME> | An isolated-auth app, in production (uppercase app name) | Same — a distinct value per isolated app is recommended but not load-bearing for isolation, since the app's scope is part of the HMAC too |
NODE_ENV | Always, in production | production — devora build/start/deploy set this themselves; only matters if you invoke the built output another way |
See Deployment for where to set environment variables on Vercel/Netlify/Docker.
Security hardening pass
Available since @devorajs/core@0.2.2. Once the backend surface (backend modules, API routes, middleware, repo-splitting, streaming — see Backend) stabilized, it went through a real internal audit — every finding below was independently reproduced (a real forged cookie, a real crafted .gitmodules path, a real oversized request body) before being fixed, not assumed from a description. Not a substitute for a formal third-party audit — but real, verified hardening in its own right:
- Cross-app session isolation is now cryptographically real, not just cookie-name-based. Before this, an
"isolated"app that happened to share its session secret with the project's shared app (a supported, documented configuration) produced signatures indistinguishable from the shared app's own cookie — a valid session from one app could be replayed verbatim against the other. The cookie name is now bound into the signed input itself, so isolation holds even when two apps' secrets collide. (Since 0.3.0 this guarantee holds in the new session model: the app's scope is part of the HMAC that derives each session's store key — see Sessions.) - Request bodies are capped (10MB default) across every API route and form action, dev and production — previously unbounded, letting a single request force unbounded memory buffering.
- Streaming connections time out (30s default) rather than holding a connection open indefinitely if a Suspense boundary never resolves — previously unbounded on self-hosted/Docker deployments.
- Path traversal closed in two places: the repo-splitting CLI (
devora split/sync/status) now refuses to run git commands or delete a directory outside the project root, even from a crafted.gitmodulesentry ordevora.config.tspath; the ISR disk cache now refuses to write or delete outside its own static output directory, even from a taintedgetStaticParams()value. devora start(the self-hosted production entrypoint) now setsNODE_ENV=productionitself, matchingdevora build/deploy— previously relying on the invoking shell to have set it, silently falling back to an insecure default session secret otherwise.
What's bring-your-own, and why
Devora.js deliberately does not ship an ORM, an auth/identity provider, or file storage. Checking who a request actually comes from — verifying a password, validating a token against an identity provider like Clerk or Lucia — is left to you, plugged into the sessions described above via your own DB client (Prisma, Drizzle, or anything else) — and so is where session records are stored (the session store is three methods over your own database). This is deliberate scope control: the framework's job is to make the plumbing (sessions, CSRF, security headers) secure and boring by default, not to compete with dedicated auth or database tooling that already does that job well. The same reasoning applies to a formal third-party security audit — not done yet, planned once the package is published and the API surface has real external users, since auditing a moving target wastes the audit.