Secure by default
Security is part of the framework, not a package you remember to install. A newly created application already refuses oversized bodies, rejects forged form posts, sets a strict Content-Security-Policy, and signs its cookies.
What a new Noderyx application protects by default, and the decisions that remain yours.
Security is part of the framework, not a package you remember to install. A newly created application already refuses oversized bodies, rejects forged form posts, sets a strict Content-Security-Policy, and signs its cookies.
Every signature—sessions, CSRF tokens, signed cookies—derives from APP_KEY. In production the application refuses to start without it; in development a temporary key is generated per process and warned about.
npx noderyx spark:key # write a key into .env
npx noderyx spark:key --show # print one without touching a file
Rotating the key signs out every user and invalidates outstanding CSRF tokens, which is why spark:key refuses to overwrite without --force.
Every response carries a policy with a fresh nonce per request. Scripts Noderyx injects carry that nonce; anything injected by an attacker does not. There is no unsafe-inline and no unsafe-eval.
default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self';
form-action 'self'; script-src 'self' 'nonce-<random>'; style-src 'self';
img-src 'self' data: blob:; connect-src 'self'; upgrade-insecure-requests
This means inline event handlers do not run. Use data-noderyx attributes, or pass the request nonce to your own script tag.
When you genuinely need a third-party origin, add it explicitly rather than loosening the whole policy.
security: {
headers: {
scriptSrc: ["'self'", "https://cdn.example.com"],
connectSrc: ["https://api.example.com"],
frameAncestors: "'none'"
}
}
Other headers ship on every response: nosniff, Referrer-Policy, X-Frame-Options, COOP, CORP, and HSTS in production.
Unsafe methods must present a token that is signed, bound to the current session, and matching the cookie. A missing or foreign token returns 419. {{csrfToken}} is available in every rendered view without passing it.
form method="post" action="/posts"
input type="hidden" name="_csrf" value="{{csrfToken}}"
input type="text" name="title"
button type="submit" "Publish"
From JavaScript, send the X-CSRF-Token header. The native bridge does this for you.
Endpoints that authenticate another way—a webhook signature, a bearer token—never rely on a cookie, so CSRF adds nothing but failures.
security: {
csrf: { exempt: ["/api/webhooks", /^\/api\/v\d+\/public/] }
}
Sessions are stateless and signed, so nothing needs a store. The cookie is HttpOnly, SameSite=Lax, and Secure in production, and any change re-issues it.
app.post("/login", async ({ body, session, json, abort }) => {
const user = await User.findByEmail(body.email);
if (!user || !await verifyPassword(body.password, user.password)) {
return abort(401, "Those details did not match");
}
session.userId = user.id;
return json({ ok: true });
});
Because the session lives in the cookie, keep it small—an id, not a profile.
validate() returns only the fields you declared, so unexpected input never reaches the database.
app.post("/users", ({ validate, json, abort }) => {
const { valid, values, errors } = validate({
email: "required|email|max:255",
name: "required|string|min:2|max:100",
role: "in:admin,editor,viewer"
});
if (!valid) return abort(422, Object.values(errors)[0]);
return json(User.create(values));
});
Rules: required, string, email, integer, number, boolean, url, min:n, max:n, in:a,b,c, alphanumeric, slug.
A packaged Android or iOS build runs from capacitor://localhost or https://localhost, so calls to your API are cross-origin. CORS is off by default—turn it on with an explicit allowlist.
security: {
cors: {
origins: ["capacitor://localhost", "https://localhost", "https://app.example.com"],
credentials: true
}
}
Allowlisted origins are added to connect-src automatically. An unknown origin gets 403 on preflight rather than a listing of what the API accepts.
The limiter answers with 429 and Retry-After, and static files are not counted. Bodies are capped and enforced while streaming, so one request cannot exhaust memory.
security: {
trustProxy: true,
bodyLimit: 5 * 1024 * 1024,
rateLimit: { windowMs: 60000, max: 300 }
}
Set trustProxy only behind a proxy you control—X-Forwarded-For is trivially spoofed, and trusting it lets one client bypass the limit entirely.
Rather than tightening eight settings by hand, SECURITY_PROFILE selects a coherent set. standard is the framework default; strict and banking progressively harden requests, sessions, and headers.
import { securityProfile } from "noderyx-framework";
security: securityProfile("banking", {
cors: { origins: ["https://app.example.com"] }
})
In a generated project the security block writes bodyLimit, rateLimit, and session after the profile spread, so those three come from your solution profile unless you remove the lines. Solution profiles explains the interaction.
Beyond the body limit, a request can be refused for its shape before a handler ever sees it. These ceilings are what the strict and banking profiles tighten.
Every one of these closes a request-smuggling or resource-exhaustion path that a body limit alone does not.
A signed session is not a short one. The stricter profiles add the two timeouts that actually limit an abandoned or stolen cookie.
security: {
session: { maxAge: 43200, absoluteMaxAge: 43200, idleTimeout: 900, rolling: true, sameSite: "Strict", secure: true }
}
rolling plus absoluteMaxAge is the combination you usually want: activity extends the session, but never past a fixed limit.
A bearer credential is the right authentication for a client that has no cookie jar. The framework generates the token, stores only its hash, and compares in constant time—so a database leak does not hand over working credentials.
import { createApiKey, requireApiKey } from "noderyx-framework";
const { token, hash, hint } = createApiKey("nyx");
// show `token` once; persist `hash` and `hint`
app.use(requireApiKey({
scopes: ["orders:read"],
async lookup(hash) {
return ApiKey.findByHash(hash);
}
}));
Return an object carrying id, hash, revoked, and scopes from lookup(). A missing or revoked key is refused with 401 and an under-scoped one with 403, both before the handler runs. On success the request carries context.principal, so a handler knows which key it is serving.
Several classes of bug are closed by the framework rather than by your review checklist.
Run through this before the first public request.
curl -sI https://example.com | grep -i "content-security-policy\|strict-transport\|x-content-type"