Skip to content

Security

Diátaxis type: Reference — information-oriented, the single source of truth for all security concerns in a Scratchy application.

Scratchy takes a defense-in-depth approach to security. Every layer — from HTTP headers to database queries — enforces its own protections so that no single misconfiguration leads to a breach. This document is the single reference for all security concerns in a Scratchy application.

Cross-references: This guide builds on patterns documented in sessions.md, middleware.md, api-design.md, and error-handling.md. Where those documents cover a topic in depth, this guide summarizes the security implications and links back.

Security Architecture Overview

                        ┌─────────────────────────────┐
                        │        Edge / CDN            │
                        │  (TLS termination, WAF)      │
                        └──────────┬──────────────────┘

                        ┌──────────▼──────────────────┐
                        │     Fastify HTTP Server      │
                        │                              │
                        │  ┌────────────────────────┐  │
                        │  │  1. Security Headers    │  │
                        │  │     (@fastify/helmet)   │  │
                        │  ├────────────────────────┤  │
                        │  │  2. Rate Limiting       │  │
                        │  │     (@fastify/rate-limit)│  │
                        │  ├────────────────────────┤  │
                        │  │  3. CORS                │  │
                        │  │     (@fastify/cors)     │  │
                        │  ├────────────────────────┤  │
                        │  │  4. CSRF Protection     │  │
                        │  │     (double-submit)     │  │
                        │  ├────────────────────────┤  │
                        │  │  5. Authentication      │  │
                        │  │     (session / JWT)     │  │
                        │  ├────────────────────────┤  │
                        │  │  6. Authorization       │  │
                        │  │     (RBAC / ownership)  │  │
                        │  ├────────────────────────┤  │
                        │  │  7. Input Validation    │  │
                        │  │     (Zod schemas)       │  │
                        │  ├────────────────────────┤  │
                        │  │  8. Business Logic      │  │
                        │  │     (tRPC / routes)     │  │
                        │  ├────────────────────────┤  │
                        │  │  9. Data Layer          │  │
                        │  │     (Drizzle ORM)       │  │
                        │  └────────────────────────┘  │
                        └──────────────────────────────┘

Each numbered layer must pass before the request reaches the next. Plugins are loaded in filename order using @fastify/autoload — prefix filenames with numbers to guarantee the correct registration sequence:

plugins/external/
├── 01-helmet.ts         # Security headers first
├── 02-rate-limit.ts     # Rate limiting second
├── 03-cors.ts           # CORS third
├── 04-csrf.ts           # CSRF fourth
└── 05-auth.ts           # Authentication last

See middleware.md for the full middleware architecture and ordering rules.


Authentication

Scratchy supports three authentication strategies depending on the consumer:

StrategyUse CaseTransport
Session-basedBrowser clients (tRPC)Signed cookie
JWT (Bearer)External API consumersAuthorization header
OAuth2 / OIDCSocial login, SSORedirect flow

Session-Based Authentication

Sessions are the primary authentication mechanism for browser-facing routes. See sessions.md for the full implementation — cookie signing, storage backends, and lifecycle management.

Security essentials:

typescript
// plugins/app/session.ts
import fp from "fastify-plugin";
import { timingSafeEqual } from "node:crypto";

export default fp(async function sessionPlugin(fastify) {
  // Cookie configuration — always sign, always httpOnly
  const cookieOptions = {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax" as const,
    path: "/",
    maxAge: 60 * 60 * 24 * 7, // 7 days
    signed: true,
  };

  fastify.decorateRequest("user", null);

  // Authenticate on every request
  fastify.addHook("onRequest", async (request, reply) => {
    const sessionId = request.cookies["session_id"];
    if (!sessionId) return;

    const session = await fastify.sessionStore.get(sessionId);
    if (!session) return;

    // Validate session expiry
    if (session.expiresAt < Date.now()) {
      await fastify.sessionStore.destroy(sessionId);
      reply.clearCookie("session_id");
      return;
    }

    request.user = session.user;
  });
});

Session regeneration on login prevents session fixation attacks:

typescript
// routers/auth/mutations.ts
import { TRPCError } from "@trpc/server";
import { ulid } from "ulid";
import { z } from "zod";
import { publicProcedure } from "~/router.js";

export const authMutations = {
  login: publicProcedure
    .input(
      z.object({
        email: z.string().email(),
        password: z.string().min(8),
      }),
    )
    .mutation(async ({ input, ctx }) => {
      const user = await verifyCredentials(input.email, input.password);
      if (!user) {
        // Use a generic message — never reveal which field was wrong
        throw new TRPCError({
          code: "UNAUTHORIZED",
          message: "Invalid email or password",
        });
      }

      // Destroy old session before creating a new one
      const oldSessionId = ctx.request.cookies["session_id"];
      if (oldSessionId) {
        await ctx.request.server.sessionStore.destroy(oldSessionId);
      }

      // Create a new session with a fresh ID
      const newSessionId = ulid();
      await ctx.request.server.sessionStore.set(newSessionId, {
        userId: user.id,
        role: user.role,
        expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
      });

      ctx.reply.setCookie("session_id", newSessionId, {
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
        sameSite: "lax",
        path: "/",
        maxAge: 60 * 60 * 24 * 7,
        signed: true,
      });

      return { userId: user.id };
    }),

  logout: publicProcedure.mutation(async ({ ctx }) => {
    const sessionId = ctx.request.cookies["session_id"];
    if (sessionId) {
      // Destroy the session — don't just unset the cookie
      await ctx.request.server.sessionStore.destroy(sessionId);
    }
    ctx.reply.clearCookie("session_id", { path: "/" });
    return { success: true };
  }),
};

JWT Validation for External APIs

JWTs are used only for external API authentication — never for browser sessions. See api-design.md for the REST route patterns.

typescript
// lib/jwt.ts
import { createSigner, createVerifier } from "fast-jwt";

const SECRET = getEnvVar("JWT_SECRET");

const verify = createVerifier({
  key: SECRET,
  algorithms: ["HS256"],
  maxAge: "1h",
  clockTolerance: 30, // 30s clock skew tolerance
});

const sign = createSigner({
  key: SECRET,
  algorithm: "HS256",
  expiresIn: "1h",
});

interface TokenPayload {
  sub: string;
  role: string;
  iat: number;
  exp: number;
}

export function verifyToken(token: string): TokenPayload {
  return verify(token) as TokenPayload;
}

export function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
  return sign(payload);
}
typescript
// hooks/external-auth.ts
import fp from "fastify-plugin";
import { verifyToken } from "~/lib/jwt.js";

export default fp(async function externalAuth(fastify) {
  fastify.decorate("verifyBearerToken", async (request: FastifyRequest) => {
    const authHeader = request.headers.authorization;
    if (!authHeader?.startsWith("Bearer ")) {
      throw fastify.httpErrors.unauthorized("Missing Bearer token");
    }

    const token = authHeader.slice(7);
    try {
      request.user = verifyToken(token);
    } catch {
      throw fastify.httpErrors.unauthorized("Invalid or expired token");
    }
  });
});

OAuth2 / OIDC Integration

Scratchy supports OAuth2 and OpenID Connect via redirect-based flows. Use @fastify/oauth2 for the transport layer and store the resulting user identity in a session.

typescript
// plugins/external/oauth2.ts
import oauthPlugin from "@fastify/oauth2";
import fp from "fastify-plugin";

export default fp(async function oauth2(fastify) {
  await fastify.register(oauthPlugin, {
    name: "googleOAuth2",
    credentials: {
      client: {
        id: getEnvVar("GOOGLE_CLIENT_ID"),
        secret: getEnvVar("GOOGLE_CLIENT_SECRET"),
      },
    },
    startRedirectPath: "/auth/google",
    callbackUri: `${getEnvVar("BASE_URL")}/auth/google/callback`,
    scope: ["openid", "email", "profile"],
    discovery: {
      issuer: "https://accounts.google.com",
    },
    pkce: "S256", // Always use PKCE
  });
});
typescript
// routes/auth/google/callback/index.ts
import type { FastifyPluginAsync } from "fastify";
import { ulid } from "ulid";

const routes: FastifyPluginAsync = async function (fastify) {
  fastify.get("/", async (request, reply) => {
    const { token } =
      await fastify.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(
        request,
      );

    // Fetch user profile from the provider
    const profile = await fetchGoogleProfile(token.access_token);

    // Upsert the user in the database
    const user = await upsertOAuthUser({
      provider: "google",
      providerId: profile.sub,
      email: profile.email,
      name: profile.name,
      image: profile.picture,
    });

    // Create a session — same flow as login
    const sessionId = ulid();
    await fastify.sessionStore.set(sessionId, {
      userId: user.id,
      role: user.role,
      expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
    });

    reply.setCookie("session_id", sessionId, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      path: "/",
      maxAge: 60 * 60 * 24 * 7,
      signed: true,
    });

    return reply.redirect("/dashboard");
  });
};

export default routes;

Better Auth Integration

Better Auth can serve as a drop-in authentication library when you need a batteries-included solution (email/ password, OAuth, magic links, two-factor) without building every flow manually.

typescript
// lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "~/db/index.js";

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "pg" }),
  emailAndPassword: {
    enabled: true,
    minPasswordLength: 10,
  },
  session: {
    cookieCache: {
      enabled: true,
      maxAge: 60 * 5, // 5 minutes
    },
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24, // Refresh every day
  },
  advanced: {
    generateId: () => ulid(),
  },
});
typescript
// plugins/app/better-auth.ts
import { toNodeHandler } from "better-auth/node";
import fp from "fastify-plugin";
import { auth } from "~/lib/auth.js";

export default fp(async function betterAuthPlugin(fastify) {
  const handler = toNodeHandler(auth);

  // Mount Better Auth on /api/auth/*
  fastify.all("/api/auth/*", async (request, reply) => {
    await handler(request.raw, reply.raw);
  });
});

Authorization

Authentication verifies who you are. Authorization decides what you can do. Scratchy enforces authorization at multiple layers.

Role-Based Access Control (RBAC)

Define roles as a const object (no enums — see TypeScript conventions):

typescript
// lib/roles.ts
const UserRole = {
  MEMBER: "member",
  MODERATOR: "moderator",
  ADMIN: "admin",
} as const;

type UserRole = (typeof UserRole)[keyof typeof UserRole];

// Permission mapping
const PERMISSIONS = {
  "posts:create": [UserRole.MEMBER, UserRole.MODERATOR, UserRole.ADMIN],
  "posts:delete": [UserRole.MODERATOR, UserRole.ADMIN],
  "users:manage": [UserRole.ADMIN],
  "settings:edit": [UserRole.ADMIN],
} as const;

type Permission = keyof typeof PERMISSIONS;

export function hasPermission(role: UserRole, permission: Permission): boolean {
  const allowedRoles: readonly UserRole[] = PERMISSIONS[permission];
  return allowedRoles.includes(role);
}

tRPC Procedure-Level Authorization

Use tRPC middleware to enforce authorization at the procedure level. This mirrors RedwoodJS SDK's per-route auth interruptors — each procedure declares its own access requirements.

typescript
// router.ts
import { TRPCError, initTRPC } from "@trpc/server";
import superjson from "superjson";
import type { Context } from "~/context.js";
import { type Permission, hasPermission } from "~/lib/roles.js";

const t = initTRPC.context<Context>().create({ transformer: superjson });

// Base middleware — requires authentication
const isAuthenticated = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "Authentication required",
    });
  }
  return next({ ctx: { user: ctx.user } });
});

// Permission middleware factory
function requirePermission(permission: Permission) {
  return t.middleware(({ ctx, next }) => {
    if (!ctx.user) {
      throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "Authentication required",
      });
    }
    if (!hasPermission(ctx.user.role, permission)) {
      throw new TRPCError({
        code: "FORBIDDEN",
        message: "You do not have permission to perform this action",
      });
    }
    return next({ ctx: { user: ctx.user } });
  });
}

export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthenticated);
export const adminProcedure = t.procedure.use(
  requirePermission("users:manage"),
);

Resource Ownership Checks

Verify that the authenticated user owns the resource they are trying to modify. This prevents IDOR (Insecure Direct Object Reference) attacks:

typescript
// routers/posts/mutations.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findPostById } from "~/db/queries/posts.js";
import { protectedProcedure } from "~/router.js";

export const postMutations = {
  update: protectedProcedure
    .input(
      z.object({
        id: z.string(),
        title: z.string().min(1).max(200).optional(),
        content: z.string().min(1).optional(),
      }),
    )
    .mutation(async ({ input, ctx }) => {
      const [post] = await findPostById.execute({ id: input.id });
      if (!post) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" });
      }

      // Ownership check — admins can bypass
      if (post.authorId !== ctx.user.id && ctx.user.role !== "admin") {
        throw new TRPCError({
          code: "FORBIDDEN",
          message: "You can only edit your own posts",
        });
      }

      const { id, ...data } = input;
      return updatePost(id, data);
    }),
};

Route-Level Auth Middleware

For Fastify REST routes, use onRequest hooks (similar to Qwik City's onRequest middleware and Nuxt's route middleware):

typescript
// hooks/require-auth.ts
import type { FastifyReply, FastifyRequest } from "fastify";

export async function requireAuth(
  request: FastifyRequest,
  reply: FastifyReply,
) {
  if (!request.user) {
    return reply.status(401).send({
      error: "Unauthorized",
      message: "Authentication required",
    });
  }
}

export function requireRole(role: string) {
  return async function roleGuard(
    request: FastifyRequest,
    reply: FastifyReply,
  ) {
    if (!request.user) {
      return reply.status(401).send({
        error: "Unauthorized",
        message: "Authentication required",
      });
    }
    if (request.user.role !== role) {
      return reply.status(403).send({
        error: "Forbidden",
        message: "Insufficient permissions",
      });
    }
  };
}
typescript
// routes/admin/index.ts
import type { FastifyPluginAsync } from "fastify";
import { requireRole } from "~/hooks/require-auth.js";

const routes: FastifyPluginAsync = async function (fastify) {
  // All routes in this scope require the admin role
  fastify.addHook("onRequest", requireRole("admin"));

  fastify.get("/dashboard", async (request) => {
    return { users: await getAdminStats() };
  });
};

export default routes;

CSRF Protection

Cross-Site Request Forgery protection is required for all state-changing operations from browser clients. See sessions.md for the full implementation of token generation, validation, and client integration.

Scratchy uses the double-submit cookie pattern — the CSRF token is stored in a signed cookie and must also be sent in a request header. The server compares the two values using constant-time comparison.

typescript
// plugins/external/04-csrf.ts
import fp from "fastify-plugin";
import { randomBytes, timingSafeEqual } from "node:crypto";

export default fp(async function csrfProtection(fastify) {
  // Generate a CSRF token and set it as a cookie
  fastify.addHook("onRequest", async (request, reply) => {
    // Only generate for GET requests (to seed the token)
    if (request.method === "GET") {
      const existingToken = request.cookies["csrf_token"];
      if (!existingToken) {
        const token = randomBytes(32).toString("hex");
        reply.setCookie("csrf_token", token, {
          httpOnly: false, // Must be readable by client JS
          secure: process.env.NODE_ENV === "production",
          sameSite: "strict",
          path: "/",
          maxAge: 60 * 60 * 24, // 24 hours
          signed: true,
        });
      }
    }
  });

  // Validate CSRF token on state-changing requests
  fastify.addHook("onRequest", async (request, reply) => {
    const safeMethods = new Set(["GET", "HEAD", "OPTIONS"]);
    if (safeMethods.has(request.method)) return;

    // Skip CSRF for external API routes (they use Bearer tokens)
    if (request.url.startsWith("/external/api")) return;

    const cookieToken = request.cookies["csrf_token"];
    const headerToken = request.headers["x-csrf-token"];

    if (!cookieToken || !headerToken || typeof headerToken !== "string") {
      return reply.status(403).send({
        error: "Forbidden",
        message: "Missing CSRF token",
      });
    }

    // Constant-time comparison to prevent timing attacks
    const cookieBuffer = Buffer.from(cookieToken, "utf8");
    const headerBuffer = Buffer.from(headerToken, "utf8");

    if (
      cookieBuffer.length !== headerBuffer.length ||
      !timingSafeEqual(cookieBuffer, headerBuffer)
    ) {
      return reply.status(403).send({
        error: "Forbidden",
        message: "Invalid CSRF token",
      });
    }
  });
});

The sameSite attribute is the first line of defense against CSRF. Scratchy defaults to "lax" for session cookies and "strict" for CSRF tokens:

CookiesameSiteReason
session_id"lax"Allows top-level navigations (OAuth redirects)
csrf_token"strict"Never sent on cross-origin requests
remember_me"lax"Needs to work on navigation from external sites

Client Integration

typescript
// lib/csrf.client.ts
function getCsrfToken(): string {
  const match = document.cookie
    .split("; ")
    .find((row) => row.startsWith("csrf_token="));
  if (!match) throw new Error("CSRF token cookie not found");
  return match.split("=")[1] ?? "";
}

// Attach the token to every tRPC and fetch request
export function createCsrfHeaders(): Record<string, string> {
  return { "x-csrf-token": getCsrfToken() };
}

Content Security Policy (CSP)

CSP is the strongest defense against XSS. Qwik's resumable architecture requires careful CSP configuration because the framework injects inline scripts for lazy-loading.

Nonce-Based CSP for Qwik

Generate a unique nonce per request and pass it to both the CSP header and the Qwik renderer:

typescript
// plugins/external/01-helmet.ts
import helmet from "@fastify/helmet";
import fp from "fastify-plugin";
import { randomBytes } from "node:crypto";

export default fp(async function helmetPlugin(fastify) {
  await fastify.register(helmet, {
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: [
          "'self'",
          // The nonce is added per-request below
          (_, res) => {
            const nonce = randomBytes(16).toString("base64");
            (res as { cspNonce?: string }).cspNonce = nonce;
            return `'nonce-${nonce}'`;
          },
        ],
        styleSrc: ["'self'", "'unsafe-inline'"], // Qwik inlines styles
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'none'"],
        frameSrc: ["'none'"],
        baseUri: ["'self'"],
        formAction: ["'self'"],
        frameAncestors: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    // Other helmet defaults
    crossOriginEmbedderPolicy: false, // May break external images
    crossOriginOpenerPolicy: { policy: "same-origin" },
    crossOriginResourcePolicy: { policy: "same-origin" },
  });

  // Make the nonce available on every request for SSR
  fastify.decorateRequest("cspNonce", "");

  fastify.addHook("onRequest", async (request) => {
    request.cspNonce = randomBytes(16).toString("base64");
  });
});

Pass the nonce to the Qwik renderer worker:

typescript
// routes/pages/index.ts
import type { FastifyPluginAsync } from "fastify";

const routes: FastifyPluginAsync = async function (fastify) {
  fastify.get("/*", async (request, reply) => {
    const result = await fastify.runTask({
      type: "ssr",
      route: request.url,
      nonce: request.cspNonce,
      props: { user: request.user },
    });

    reply
      .status(result.statusCode)
      .header("content-type", "text/html; charset=utf-8")
      .send(result.html);
  });
};

export default routes;

CSP Report-Only Mode

Use report-only mode to test a new policy before enforcing it:

typescript
// Start in report-only mode to identify violations
fastify.addHook("onSend", async (request, reply) => {
  if (process.env.CSP_REPORT_ONLY === "true") {
    const csp = reply.getHeader("content-security-policy");
    if (typeof csp === "string") {
      reply.removeHeader("content-security-policy");
      reply.header("content-security-policy-report-only", csp);
    }
  }
});

Set up a reporting endpoint to collect CSP violations:

typescript
// routes/csp-report/index.ts
import type { FastifyPluginAsync } from "fastify";
import { z } from "zod";

const cspReportSchema = z.object({
  "csp-report": z.object({
    "document-uri": z.string(),
    "violated-directive": z.string(),
    "blocked-uri": z.string(),
    "original-policy": z.string(),
  }),
});

const routes: FastifyPluginAsync = async function (fastify) {
  fastify.post(
    "/csp-report",
    { schema: { body: cspReportSchema } },
    async (request) => {
      request.log.warn({ cspViolation: request.body }, "CSP violation report");
      return { received: true };
    },
  );
};

export default routes;

Security Headers

@fastify/helmet sets most headers automatically. This section documents what each header does and how to configure it.

Header Reference

HeaderValuePurpose
X-Content-Type-OptionsnosniffPrevents MIME-type sniffing
X-Frame-OptionsDENYBlocks framing (clickjacking)
Strict-Transport-Securitymax-age=31536000; includeSubDomainsForces HTTPS for 1 year
Referrer-Policystrict-origin-when-cross-originLimits referrer leakage
X-DNS-Prefetch-ControloffPrevents DNS prefetch leaking
X-Permitted-Cross-Domain-PoliciesnoneBlocks Flash/PDF cross-domain access
Content-Security-Policy(per-request nonce)XSS prevention (see CSP section)
Cross-Origin-Opener-Policysame-originIsolates browsing context
Cross-Origin-Resource-Policysame-originPrevents cross-origin resource reads
Permissions-Policycamera=(), microphone=()Disables sensitive browser features

Complete Helmet Configuration

typescript
// plugins/external/01-helmet.ts
import helmet from "@fastify/helmet";
import fp from "fastify-plugin";

export default fp(async function helmetPlugin(fastify) {
  await fastify.register(helmet, {
    // CSP — see Content Security Policy section above
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        frameSrc: ["'none'"],
        baseUri: ["'self'"],
        formAction: ["'self'"],
        frameAncestors: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },

    // HSTS — force HTTPS
    strictTransportSecurity: {
      maxAge: 31_536_000, // 1 year
      includeSubDomains: true,
      preload: true,
    },

    // Prevent clickjacking
    frameguard: { action: "deny" },

    // Prevent MIME-type sniffing
    contentTypeOptions: true, // X-Content-Type-Options: nosniff

    // Referrer policy
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },

    // Disable DNS prefetching
    dnsPrefetchControl: { allow: false },

    // Disable Flash/PDF cross-domain access
    permittedCrossDomainPolicies: { permittedPolicies: "none" },

    // Browsing context isolation
    crossOriginOpenerPolicy: { policy: "same-origin" },
    crossOriginResourcePolicy: { policy: "same-origin" },
    crossOriginEmbedderPolicy: false,
  });
});

Permissions-Policy Header

Helmet doesn't manage Permissions-Policy. Add it manually:

typescript
fastify.addHook("onSend", async (_request, reply) => {
  reply.header(
    "permissions-policy",
    "camera=(), microphone=(), geolocation=(), payment=()",
  );
});

Strip Internal-Routing and Framework Headers

@scratchyjs/core automatically loads a strip-internal-headers plugin that removes generic internal-routing request headers and the Fastify server response header.

Request headers stripped:

HeaderRisk
x-internal-requestAuth bypass if any code trusts this to identify an internal caller
x-internal-tokenCredential injection via forged token value

Response headers stripped:

HeaderWhy
serverFastify sets this to "Fastify" by default — removing it reduces the attack surface by hiding implementation details from potential attackers

To add application-specific headers to the strip list, create a separate hook plugin in your plugins/app/ directory:

typescript
// plugins/app/strip-app-headers.ts
import fp from "fastify-plugin";

export default fp(
  function stripAppHeaders(fastify, _opts, done) {
    fastify.addHook("onRequest", (request, _reply, hookDone) => {
      delete request.headers["x-my-app-bypass"];
      hookDone();
    });
    done();
  },
  { name: "strip-app-internal-headers" },
);

Never trust internal headers for auth decisions — all auth must flow through requireAuth / protectedProcedure based on actual session state.


Input Validation and Sanitization

Zod Schema Validation

Every input boundary — tRPC procedures, REST route bodies, query strings — must be validated with Zod. Never trust client input.

typescript
// Strict input schemas — no extra properties, no loose types
const createUserInput = z.object({
  name: z
    .string()
    .min(1, "Name is required")
    .max(100, "Name must be 100 characters or fewer")
    .trim(),
  email: z.string().email("Invalid email address").toLowerCase(),
  password: z
    .string()
    .min(10, "Password must be at least 10 characters")
    .max(128, "Password must be 128 characters or fewer"),
});

// Pagination — always constrain limits
const paginationInput = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

// ID parameters — constrain format
const idInput = z.object({
  id: z.string().min(1).max(64),
});

// Search queries — prevent ReDoS by limiting length
const searchInput = z.object({
  query: z.string().min(1).max(200).trim(),
});

SQL Injection Prevention

Drizzle ORM uses parameterized queries by default. Every value passed through the query builder is automatically escaped. The only injection risk comes from building raw SQL.

typescript
import { sql, eq } from "drizzle-orm";

// ✅ SAFE — Drizzle parameterizes automatically
const users = await db
  .select()
  .from(user)
  .where(eq(user.email, userInput));

// ✅ SAFE — sql.placeholder for prepared statements
const findUser = db
  .select()
  .from(user)
  .where(eq(user.id, sql.placeholder("id")))
  .prepare("find_user");
await findUser.execute({ id: untrustedId });

// ✅ SAFE — sql template tag parameterizes the value
const results = await db.execute(
  sql`SELECT * FROM users WHERE email = ${userInput}`,
);

// ❌ DANGEROUS — string concatenation in raw SQL
const results = await db.execute(
  sql.raw(`SELECT * FROM users WHERE email = '${userInput}'`),
);

Rule: Never use sql.raw() with untrusted input. If you need dynamic identifiers (table or column names), validate them against an allowlist.

XSS Prevention

Qwik automatically escapes JSX expressions, which prevents most reflected and stored XSS. Additional protections:

  1. Never use dangerouslySetInnerHTML (or Qwik equivalent) with user-provided content without sanitization.
  2. Sanitize HTML if you must render user HTML — use a strict allowlist sanitizer:
typescript
import DOMPurify from "isomorphic-dompurify";

function sanitizeHtml(dirty: string): string {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br", "ul", "ol", "li"],
    ALLOWED_ATTR: ["href", "target", "rel"],
  });
}
  1. Set httpOnly: true on all session cookies (prevents JS access).
  2. Set a strict CSP (see CSP section above).
  3. Encode output in non-HTML contexts (JSON, URLs, CSS):
typescript
// URL parameters — use encodeURIComponent
const safeUrl = `/search?q=${encodeURIComponent(userQuery)}`;

// JSON in HTML — escape angle brackets
function escapeJsonForHtml(json: string): string {
  return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
}

Rate Limiting

Rate limiting prevents abuse, brute-force attacks, and resource exhaustion. Scratchy uses @fastify/rate-limit with Redis/DragonflyDB as the store for distributed deployments.

Global Rate Limit

typescript
// plugins/external/02-rate-limit.ts
import rateLimit from "@fastify/rate-limit";
import fp from "fastify-plugin";

export default fp(async function rateLimitPlugin(fastify) {
  await fastify.register(rateLimit, {
    global: true,
    max: 100,
    timeWindow: "1 minute",
    // Use Redis for distributed rate limiting
    redis: fastify.redis,
    // Identify clients by IP or API key
    keyGenerator: (request) => {
      return (request.headers["x-api-key"] as string) || request.ip;
    },
    // Return standard error response
    errorResponseBuilder: (request, context) => ({
      statusCode: 429,
      error: "Too Many Requests",
      message: `Rate limit exceeded. Retry after ${Math.ceil(context.ttl / 1000)} seconds.`,
      retryAfter: Math.ceil(context.ttl / 1000),
    }),
    // Add rate limit headers to every response
    addHeadersOnExceeding: {
      "x-ratelimit-limit": true,
      "x-ratelimit-remaining": true,
    },
    addHeaders: {
      "x-ratelimit-limit": true,
      "x-ratelimit-remaining": true,
      "retry-after": true,
    },
  });
});

Per-Route Rate Limits

Apply stricter limits on sensitive endpoints:

typescript
// routes/auth/login/index.ts
import type { FastifyPluginAsync } from "fastify";

const routes: FastifyPluginAsync = async function (fastify) {
  fastify.post(
    "/login",
    {
      config: {
        rateLimit: {
          max: 5,
          timeWindow: "15 minutes",
          keyGenerator: (request) => {
            // Rate limit by IP for login attempts
            return `login:${request.ip}`;
          },
        },
      },
    },
    async (request, reply) => {
      // Login handler
    },
  );

  // Password reset — even stricter
  fastify.post(
    "/reset-password",
    {
      config: {
        rateLimit: {
          max: 3,
          timeWindow: "1 hour",
          keyGenerator: (request) => `reset:${request.ip}`,
        },
      },
    },
    async (request, reply) => {
      // Password reset handler
    },
  );
};

export default routes;

Per-User / Per-API-Key Limits

For external APIs, rate limit by API key to prevent a single consumer from exhausting resources:

typescript
// routes/external/api/v1/index.ts
import type { FastifyPluginAsync } from "fastify";

const routes: FastifyPluginAsync = async function (fastify) {
  fastify.addHook("onRequest", async (request, reply) => {
    const apiKey = request.headers["x-api-key"];
    if (!apiKey || typeof apiKey !== "string") {
      return reply.status(401).send({ error: "Missing API key" });
    }
  });

  // Route-scoped rate limit by API key
  await fastify.register(import("@fastify/rate-limit"), {
    max: 1000,
    timeWindow: "1 hour",
    keyGenerator: (request) => {
      return `api:${request.headers["x-api-key"] as string}`;
    },
  });
};

export default routes;

DDoS Protection Strategies

Rate limiting alone is not sufficient for DDoS protection. Layer these strategies:

LayerStrategy
Edge/CDNCloudflare, AWS Shield, or similar DDoS mitigation
Reverse proxyConnection limits in Nginx/Caddy
Application@fastify/rate-limit with Redis backend
Request sizebodyLimit in Fastify config (default 10 MB)
TimeoutsrequestTimeout and keepAliveTimeout in Fastify
Slow lorisconnectionTimeout at the reverse proxy level
typescript
// server.ts — request-level protections
const server = Fastify({
  bodyLimit: 10 * 1024 * 1024, // 10 MB max body
  requestTimeout: 30_000, // 30 second request timeout
  keepAliveTimeout: 5_000, // 5 second keep-alive timeout
});

CORS Configuration

CORS is enabled only on external API routes (/external/api). Internal tRPC endpoints serve same-origin requests and must never have CORS headers.

See api-design.md for the full CORS strategy.

Production Origin Allowlist (ALLOWED_ORIGINS)

The @scratchyjs/core CORS plugin reads process.env.NODE_ENV and process.env.ALLOWED_ORIGINS at startup to determine the origin policy:

EnvironmentALLOWED_ORIGINSBehaviour
development / testanyorigin: true — all origins accepted
productionsetExplicit allowlist callback — only listed origins
productionnot setorigin: false — all cross-origin requests denied
bash
# .env.production — required for cross-origin browser clients
ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com

A startup warning is logged when NODE_ENV=production and ALLOWED_ORIGINS is unset so the misconfiguration is visible immediately in server logs.

Internal API (No CORS)

typescript
// tRPC routes — no CORS, same-origin only
await server.register(fastifyTRPCPlugin, {
  prefix: "/trpc",
  trpcOptions: { router: appRouter, createContext },
});
// No @fastify/cors registered — browser same-origin policy applies

External API (CORS Enabled)

typescript
// plugins/external/03-cors.ts — scoped to external routes only
import cors from "@fastify/cors";
import fp from "fastify-plugin";

export default fp(async function corsPlugin(fastify) {
  // Only apply CORS to external API routes
  fastify.register(
    async function externalScope(instance) {
      await instance.register(cors, {
        origin: (origin, callback) => {
          const allowedOrigins = getEnvVar("CORS_ALLOWED_ORIGINS").split(",");
          if (!origin || allowedOrigins.includes(origin)) {
            callback(null, true);
          } else {
            callback(new Error("Not allowed by CORS"), false);
          }
        },
        methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
        allowedHeaders: ["Content-Type", "Authorization", "X-API-Key"],
        exposedHeaders: [
          "X-RateLimit-Limit",
          "X-RateLimit-Remaining",
          "Retry-After",
        ],
        credentials: true,
        maxAge: 86_400, // 24 hours preflight cache
      });
    },
    { prefix: "/external/api" },
  );
});

Anti-Patterns

typescript
// ❌ DANGEROUS — Allow all origins
await fastify.register(cors, { origin: true });

// ❌ DANGEROUS — Wildcard with credentials
await fastify.register(cors, { origin: "*", credentials: true });

// ❌ DANGEROUS — CORS on tRPC routes
await fastify.register(cors); // Applies to all routes

// ✅ SAFE — Explicit origin allowlist, scoped to external routes
await fastify.register(cors, {
  origin: ["https://partner-app.example.com"],
  credentials: true,
});

Secrets Management

Environment Variables

All secrets are loaded from environment variables — never hardcoded in source.

typescript
// lib/env.ts
function getEnvVar(key: string): string {
  const value = process.env[key];
  if (value === undefined || value === "") {
    throw new Error(`Missing required environment variable: ${key}`);
  }
  return value;
}

// Required environment variables
const config = {
  databaseUrl: getEnvVar("DATABASE_URL"),
  redisUrl: getEnvVar("REDIS_URL"),
  sessionSecret: getEnvVar("SESSION_SECRET"),
  jwtSecret: getEnvVar("JWT_SECRET"),
  cookieSecret: getEnvVar("COOKIE_SECRET"),
  googleClientId: getEnvVar("GOOGLE_CLIENT_ID"),
  googleClientSecret: getEnvVar("GOOGLE_CLIENT_SECRET"),
} as const;

Secret Rotation

Cookie signing and JWT secrets support rotation — multiple secrets where the first is used for signing and all are tried for verification. This allows zero-downtime secret rotation.

typescript
// lib/cookie-secrets.ts
import { createHmac, timingSafeEqual } from "node:crypto";

// COOKIE_SECRETS is a comma-separated list, newest first
const secrets = getEnvVar("COOKIE_SECRETS").split(",");

// Sign with the newest secret
export function signCookie(value: string): string {
  const signature = createHmac("sha256", secrets[0]!)
    .update(value)
    .digest("base64url");
  return `${value}.${signature}`;
}

// Verify against all secrets (supports rotation)
export function verifyCookie(signed: string): string | null {
  const dotIndex = signed.lastIndexOf(".");
  if (dotIndex === -1) return null;

  const value = signed.slice(0, dotIndex);
  const signature = signed.slice(dotIndex + 1);
  const sigBuffer = Buffer.from(signature, "base64url");

  for (const secret of secrets) {
    const expected = createHmac("sha256", secret).update(value).digest();

    if (
      sigBuffer.length === expected.length &&
      timingSafeEqual(sigBuffer, expected)
    ) {
      return value;
    }
  }

  return null; // No secret matched
}

Rotation procedure:

  1. Generate a new secret.
  2. Prepend it to COOKIE_SECRETS (e.g., new_secret,old_secret).
  3. Deploy — new cookies are signed with the new secret; old cookies still verify against the old secret.
  4. After all old sessions expire (e.g., 7 days), remove the old secret.

Rules

  • Never commit secrets to version control — use .env files (gitignored) or a secrets manager.
  • Use separate secrets for each purpose — don't reuse the session secret as the JWT secret.
  • Rotate secrets periodically — at minimum when team members leave or a breach is suspected.
  • Use strong secrets — at least 256 bits of randomness:
bash
# Generate a cryptographically secure secret
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"

Password Storage

Never store passwords in plain text. Use a memory-hard hashing algorithm:

typescript
// lib/password.ts
import { hash, verify } from "@node-rs/argon2";

const ARGON2_OPTIONS = {
  memoryCost: 65536, // 64 MB
  timeCost: 3,
  parallelism: 4,
  outputLen: 32,
};

export async function hashPassword(password: string): Promise<string> {
  return hash(password, ARGON2_OPTIONS);
}

export async function verifyPassword(
  hash: string,
  password: string,
): Promise<boolean> {
  return verify(hash, password, ARGON2_OPTIONS);
}

Rules:

  • Use Argon2id (preferred) or bcrypt (acceptable).
  • Never use MD5, SHA-1, SHA-256, or other fast hashes for passwords.
  • Hash on the server — never accept pre-hashed passwords from the client.
  • Never log passwords, even in development.

Dependency Security

Known CVEs Addressed in This Stack

The following CVEs have been researched and mitigated within Scratchy. Each entry lists the status and what action (if any) is required:

CVEComponentTypeSeverityStatusAction
CVE-2025-32442Fastify ≤ 5.3.1Content-Type validation bypass → injectionHigh✅ Use single Zod schema per routeKeep Fastify ≥ 5.3.2
CVE-2025-43855@trpc/server 11.0–11.1.0WebSocket connectionParams uncaught exception → DoSHighcreateContext() wrapped in try/catchKeep @trpc/server ≥ 11.1.1
CVE-2025-29927Next.js (pattern)Internal header bypass of auth middlewareCriticalstrip-internal-headers pluginBuilt into @scratchyjs/core
CVE-2024-8024@fastify/cors (pattern)origin: true + credentials → credential exfiltrationHigh✅ CORS plugin auto-restricts in productionSet ALLOWED_ORIGINS in production
CVE-2024-22207@fastify/swagger-ui < 2.1.0Serves module directory files → info disclosureMedium✅ Using swagger-ui ≥ 5.xNo action needed
CVE-2025-43864Remix (pattern)Cache poisoning + persistent XSSHigh✅ tRPC uses no-store; add Vary: Cookie for SSR pagesAdd Cache-Control: private, no-store + Vary: Cookie to authenticated routes
CVE-2025-61686Remix (pattern)Path traversal via file-based session storageCritical✅ Redis sessions + signed cookiesNever use file-based session storage
CVE-2025-61928better-auth < 1.3.26API key plugin auth bypass → unauthenticated takeoverCritical✅ Using better-auth 1.5.5 (patched)Keep better-auth ≥ 1.3.26; avoid enabling apiKey plugin unless required

Upgrade guidance for CVE-2025-32442 (Fastify Content-Type bypass): This CVE affects per-content-type schema configurations. The Scratchy pattern of using a single Zod schema per route (via fastify-type-provider-zod) is the safe pattern. Ensure Fastify is kept at 5.3.2+ in your package.json.

Upgrade guidance for CVE-2025-43855 (tRPC WebSocket DoS): The createContext() function in @scratchyjs/trpc is already wrapped in a try/catch to prevent uncaught exceptions. Keep @trpc/server at ≥ 11.1.1.

Audit Commands

bash
# Check for known vulnerabilities in dependencies
pnpm audit

# Check for outdated packages
pnpm outdated

# Update dependencies (review changes before merging)
pnpm update --interactive

Supply Chain Security

PracticeImplementation
Lock file integrityAlways commit pnpm-lock.yaml; use --frozen-lockfile in CI
Minimal dependenciesAudit every new dependency; prefer Node.js built-ins
Pinned versionsUse exact versions in package.json for production deps
CI vulnerability scanningRun pnpm audit in CI and fail on critical/high findings
Provenance checksVerify package provenance with npm audit signatures
CorepackPin pnpm version via "packageManager" in package.json
jsonc
// package.json
{
  "packageManager": "pnpm@10.x.x",
  "scripts": {
    "audit": "pnpm audit --audit-level=high",
    "preinstall": "npx only-allow pnpm",
  },
}

Logging and Audit Trail

Security events must be logged for incident response and compliance. Use structured logging with Pino (Fastify's built-in logger).

typescript
// Log authentication events
request.log.info({ userId: user.id, method: "password" }, "user logged in");
request.log.info({ userId: user.id }, "user logged out");
request.log.warn({ userId: user.id, ip: request.ip }, "failed login attempt");

// Log authorization failures
request.log.warn(
  { userId: request.user?.id, resource: "post", action: "delete", postId },
  "authorization denied",
);

// Log security events
request.log.warn({ ip: request.ip, path: request.url }, "rate limit exceeded");
request.log.warn({ ip: request.ip }, "CSRF token validation failed");
request.log.warn({ cspViolation: report }, "CSP violation");

Rules:

  • Never log secrets, passwords, tokens, or full credit card numbers.
  • Always log failed authentication attempts with the source IP.
  • Always log authorization denials with the user ID and requested resource.
  • Use request.log inside handlers (includes request context), fastify.log only in plugin-level code.

Worker Thread Security

Piscina workers run in separate V8 isolates. Security considerations:

  1. Do not pass secrets directly to worker task payloads. Workers should read secrets from their own environment or a secure store.
  2. Validate worker input — treat task payloads as untrusted and validate them in the worker:
typescript
// renderer/worker.ts
import { z } from "zod";

const renderTaskSchema = z.object({
  type: z.enum(["ssr", "ssg"]),
  route: z.string().min(1).max(2048),
  nonce: z.string().min(1).max(64).optional(),
  props: z.record(z.unknown()).optional(),
});

export default async function handler(rawTask: unknown) {
  const task = renderTaskSchema.parse(rawTask);
  // Safe to use task.route, task.nonce, etc.
}
  1. Set resource limits on workers to prevent memory exhaustion:
typescript
{
  resourceLimits: {
    maxOldGenerationSizeMb: 512,
    maxYoungGenerationSizeMb: 64,
  },
  taskTimeout: 30_000,
}

Security Checklist

Use this checklist before every production deployment:

Authentication and Sessions

  • [ ] Session cookies use httpOnly: true
  • [ ] Session cookies use secure: true in production
  • [ ] Session cookies use sameSite: "lax" or "strict"
  • [ ] All cookies are signed with HMAC-SHA256
  • [ ] Session ID is regenerated on login
  • [ ] Sessions are destroyed (not just cleared) on logout
  • [ ] Passwords are hashed with Argon2id or bcrypt
  • [ ] Failed login returns a generic error message

CSRF

  • [ ] CSRF tokens are validated on all state-changing requests
  • [ ] CSRF comparison uses timingSafeEqual
  • [ ] CSRF tokens use sameSite: "strict"
  • [ ] External API routes are excluded from CSRF (they use Bearer tokens)

Headers

  • [ ] @fastify/helmet is registered as the first plugin
  • [ ] CSP is configured with nonces (no 'unsafe-inline' for scripts)
  • [ ] HSTS is enabled with includeSubDomains and preload
  • [ ] X-Frame-Options: DENY is set
  • [ ] Permissions-Policy disables unused browser features
  • [ ] strip-internal-headers plugin is not removed from @scratchyjs/core

Input and Output

  • [ ] All inputs are validated with Zod schemas
  • [ ] All database queries use Drizzle ORM (parameterized)
  • [ ] No sql.raw() with untrusted input
  • [ ] User HTML is sanitized before rendering
  • [ ] Error responses do not leak internal details in production

Rate Limiting and CORS

  • [ ] Global rate limit is configured
  • [ ] Login and password reset have stricter rate limits
  • [ ] CORS is enabled only on /external/api routes
  • [ ] CORS origins are explicitly allowlisted
  • [ ] ALLOWED_ORIGINS is set in production environment
  • [ ] Authenticated SSR responses include Cache-Control: private, no-store and Vary: Cookie

Secrets

  • [ ] No secrets in source code or version control
  • [ ] Separate secrets for each purpose (session, JWT, cookie)
  • [ ] Secret rotation is supported (multiple secrets)
  • [ ] Secrets are at least 256 bits of randomness

Dependencies

  • [ ] pnpm audit runs in CI
  • [ ] pnpm-lock.yaml is committed
  • [ ] CI uses --frozen-lockfile
  • [ ] Known CVE table reviewed — all items marked ✅

Anti-Patterns

❌ Don't trust client-side validation alone

typescript
// BAD — Only validating on the client
// The server receives unvalidated data

// GOOD — Always validate on the server
const input = createUserInput.parse(request.body);

❌ Don't expose internal errors in production

typescript
// BAD — Leaks database schema information
return reply.status(500).send({ error: dbError.message });

// GOOD — Generic message, log the real error
request.log.error(dbError, "database operation failed");
return reply.status(500).send({
  error: "Internal Server Error",
  message: "An unexpected error occurred",
});

See error-handling.md for the full error handling strategy.

❌ Don't store sensitive data in JWT claims

typescript
// BAD — JWTs are base64-encoded, not encrypted
const token = signToken({
  sub: user.id,
  email: user.email,       // PII in token
  creditCard: user.card,   // Sensitive data!
});

// GOOD — Minimal claims, look up details server-side
const token = signToken({
  sub: user.id,
  role: user.role,
});

❌ Don't skip authorization checks

typescript
// BAD — Any authenticated user can delete any post
export const deletePost = protectedProcedure
  .input(z.object({ id: z.string() }))
  .mutation(async ({ input }) => {
    await db.delete(post).where(eq(post.id, input.id));
  });

// GOOD — Verify ownership or admin role
export const deletePost = protectedProcedure
  .input(z.object({ id: z.string() }))
  .mutation(async ({ input, ctx }) => {
    const [existing] = await findPostById.execute({ id: input.id });
    if (!existing) {
      throw new TRPCError({ code: "NOT_FOUND" });
    }
    if (existing.authorId !== ctx.user.id && ctx.user.role !== "admin") {
      throw new TRPCError({ code: "FORBIDDEN" });
    }
    await db.delete(post).where(eq(post.id, input.id));
  });

❌ Don't use === for token comparison

typescript
// BAD — Vulnerable to timing attacks
if (csrfCookie === csrfHeader) {
  /* ... */
}

// GOOD — Constant-time comparison
const a = Buffer.from(csrfCookie, "utf8");
const b = Buffer.from(csrfHeader, "utf8");
if (a.length === b.length && timingSafeEqual(a, b)) {
  /* ... */
}

❌ Don't enable CORS on internal routes

typescript
// BAD — Opens tRPC to cross-origin requests
await server.register(cors);

// GOOD — Only on external API routes
await server.register(cors, { prefix: "/external/api" });