Security

Lunora's secure-by-default posture — the protections you get for free, how to opt out, and the advisor lints that catch the gaps.

Last updated:

Lunora is secure by default. The edge protections, auth-cookie hardening, and input guards described below are on the moment you run createWorker. You don't opt in to them, you opt out. Every layer can be disabled individually for the rare case that needs it, and the advisors flag the gaps that defaults can't close on their own (a public mutation with no rate limit, a v.any() argument, a secret checked into source).

This page covers the framework defaults, the public helpers, and the full security advisor catalog with remediation.

The HTTP edge

The worker's top-level fetch is the single choke point every response passes through: RPC, auth, admin, httpRoute handlers, and the SSR fallback alike. Three layers are applied there, all on by default and configured through the security option on createWorker. None ever overwrites a header or decision an inner handler already made; defaults fill in, they never clobber.

Baseline response headers

Added to every response when the inner handler didn't set them:

HeaderDefault
Strict-Transport-Securitymax-age=31536000; includeSubDomains (HTTPS responses only)
X-Content-Type-Optionsnosniff
X-Frame-OptionsSAMEORIGIN
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policya minimal deny list (camera, microphone, geolocation, payment, …)
Cross-Origin-Opener-Policysame-origin
Content-Security-Policydefault-src 'none'; frame-ancestors 'none'; … on non-HTML responses

The CSP default is applied only to non-HTML (API/JSON) responses, so an SSR page is never broken by a policy it didn't opt into. Pass a csp string to apply your own policy to HTML too:

createWorker({
    security: {
        headers: {
            csp: "default-src 'self'; script-src 'self' 'unsafe-inline'",
            hsts: { maxAge: 63072000, preload: true },
        },
    },
});

Set headers: false to disable the layer entirely, or any individual field to false to omit just that header.

CORS — deny cross-origin by default

Cross-origin requests are denied unless you name an allowlist. There is no wildcard fallback:

createWorker({
    security: {
        cors: {
            allowedOrigins: ["https://app.example.com"], // or a predicate
            allowCredentials: true,
        },
    },
});

When you don't pass security.cors in code, the worker falls back to env config: LUNORA_ALLOWED_ORIGINS is a comma-separated allowlist and LUNORA_CORS_ALLOW_CREDENTIALS (true/on/1) opts credentials in. This lets a deployment set its origins through wrangler vars without a code change. Code config always wins — an explicit security.cors ignores the env vars entirely.

A * wildcard combined with credentials is rejected: browsers refuse the combination and it defeats the allowlist. Set in code it throws at construction; set through the env vars the worker keeps serving the allowlist but silently drops credentials (it can't throw in the request path), and the @lunora/config validator catches the same mistake at build time. Preflight (OPTIONS) is answered automatically for allowlisted origins.

CSRF / origin guard

State-changing, cookie-authenticated requests whose Origin/Referer is neither same-origin nor allowlisted are rejected with 403. The guard is scoped deliberately to cookie-bearing browser requests, the only vector a cross-site forgery can ride. Bearer-token and server-to-server traffic (no Cookie header) is exempt, as are safe methods (GET/HEAD/OPTIONS).

createWorker({
    security: {
        csrf: { trustedOrigins: ["https://admin.example.com"] }, // or `false` to disable
    },
});

Body cap

Request bodies are capped at 1 MiB before they reach a handler, so an oversized upload can't exhaust the worker's memory. This is always on.

Auth, cookies, and secrets

createAuth (@lunora/auth) hardens better-auth's defaults unless you override them:

  • Cookie attributeshttpOnly: true, sameSite: "lax", path: "/" are filled in when you don't supply advanced.defaultCookieAttributes.
  • Secure cookiesuseSecureCookies is forced on for an HTTPS baseURL.
  • Auth secret strengthAUTH_SECRET shorter than 32 characters logs a warning (not a throw, so dev isn't blocked) pointing at openssl rand -hex 32. A missing secret still throws.

Protecting public procedures

protectPublic (@lunora/server) is thin sugar that chains the recommended guards — a rate limit, then a CAPTCHA — into one .use()-able middleware. It is the recommended wrapper for any public mutation that creates users, sends mail, or consumes credits:

import { protectPublic } from "lunorash/server";
import { rateLimit } from "@lunora/ratelimit";
import { verifyTurnstileMiddleware } from "@lunora/auth";

export const signUp = mutation
    .use(
        protectPublic({
            rateLimit: rateLimit(limiter, "signup"),
            captcha: verifyTurnstileMiddleware({ secret: env.TURNSTILE_SECRET_KEY, token: (c) => c.args.captchaToken }),
        }),
    )
    .mutation(async ({ ctx, args }) => {
        /* ... */
    });

The rate limit runs first (the cheapest gate, shedding obvious floods before the Turnstile round-trip). Omitted fields are skipped; an empty bundle passes through unchanged.

Advisor catalog

Every protection above has a matching advisor lint, so an app that opts out, or never opted in, is warned at build time. The static lints run on every lunora dev save and on deploy.

LintLevelRemediation
hardcoded_secreterrorRotate the leaked value and move it to a wrangler secret / binding, never source.
sql_injection_riskerrorPass request input through a bound ctx.sql placeholder, never string concatenation.
rls_uncovered_tablewarnAdd .use(rls(...)) to the public procedure, matching the policy used elsewhere.
mask_uncovered_pii_columnwarnAdd .use(mask(...)) so the public read redacts the same columns as elsewhere.
auth_api_call_without_headerswarnPass headers to ctx.authApi.<method>(...) so better-auth authorizes the session.
public_mutation_without_ratelimitwarnWrap the public write with protectPublic({ rateLimit }) or attach rateLimit(...).
user_creating_mutation_without_captchawarnAdd verifyTurnstile / protectPublic({ captcha }) to the user-creating procedure.
public_arg_uses_anywarnReplace the v.any() argument with a concrete validator.
admin_route_without_guardwarnReference an auth/admin guard in the privileged httpRoute handler.
unbounded_string_arginfoAdd a max-length .check to the public string argument.
container_public_internetinfoSet enableInternet: false on the container if it needs no outbound egress.

Deployment-level findings

A second set is computed at runtime from the Worker env and surfaces in the Studio's Security Advisor panel. These observe the live deployment rather than the source, so they only fire once a LUNORA_ADMIN_TOKEN gates introspection:

FindingLevelFires when
admin-token-weakwarnLUNORA_ADMIN_TOKEN is set but shorter than 24 chars.
auth-secret-weakwarnAUTH_SECRET / BETTER_AUTH_SECRET is set but shorter than 32 chars.
ws-gate-openerror*LUNORA_WS_BEARER is unset, so live admin subscriptions need no credential.
cors-wildcard-credentialserrorLUNORA_ALLOWED_ORIGINS includes * while LUNORA_CORS_ALLOW_CREDENTIALS is on.
security-headers-disabledwarnLUNORA_SECURITY_HEADERS is off on a production worker.
csrf-disabledwarnLUNORA_SECURITY_CSRF is off on a production worker.
cookies-insecurewarnBETTER_AUTH_URL is a plaintext http:// origin on a production worker.
dev-args-unredactedwarnThe worker reports a dev environment, so the request log keeps un-redacted args/identity.
* downgraded to info on a local dev worker, where an open gate is expected.

The LUNORA_SECURITY_HEADERS and LUNORA_SECURITY_CSRF env vars are the env-level opt-outs for the corresponding security layers; setting either to off/false/0 relaxes the secure default. Code config always wins: an explicit security.headers / security.csrf in createWorker overrides the env knob, so the audit and the running worker never disagree.

Config-time validation

@lunora/config's wrangler validator (run by the CLI and Vite plugin on every build) rejects the wildcard-CORS-with-credentials combination when it is driven through wrangler vars:

{
    "vars": {
        "LUNORA_ALLOWED_ORIGINS": "*",
        "LUNORA_CORS_ALLOW_CREDENTIALS": "true", // ← build fails here
    },
}

This is the build-time twin of the runtime construction throw, so the misconfiguration surfaces before you deploy.

See also