StarterApp Docs
Packages

@workspace/security

Security utilities for API responses and headers

Server-side security functions for JSON responses and HTML documents. Provides consistent header application, cache control, and CSP management.

Heads up

This package is server-only. Importing in client code throws an error. Use security functions exclusively in API routes, Server Components, and middleware.

Core Response Functions

secureJson()

Base JSON response function with security headers.

Basic usage
import { secureJson } from "@workspace/security";

export async function GET() {
  return secureJson({ message: "Hello" });
}

Default Headers

  • Content-Type: application/json; charset=utf-8
  • X-Content-Type-Options: nosniff

Options

secureJson options
secureJson(body, {
  status?: number;          // HTTP status (default: 200)
  headers?: HeadersInit;    // Additional headers
  cache?: CachePolicy;      // Cache control (default: "none")
  vary?: string[];          // Vary header values
  includePoweredByRemoval?: boolean; // Remove Server/X-Powered-By (default: true)
})

secureUserJson()

JSON response for authenticated user data. Applies private cache headers and authentication-specific Vary headers.

User-scoped response
import { secureUserJson } from "@workspace/security";
import { headers } from "next/headers";
import { auth } from "@workspace/auth/server";

export async function GET() {
  const session = await auth.api.getSession({
    headers: await headers()
  });

  if (!session?.user?.id) {
    return secureErrorJson(
      { message: "Unauthorized", code: "UNAUTHORIZED" },
      { status: 401 }
    );
  }

  const profile = await getUserProfile(session.user.id);
  return secureUserJson(profile);
}

Applied Headers

  • Cache-Control: private, no-store, max-age=0, must-revalidate
  • Pragma: no-cache
  • Expires: 0
  • Vary: Cookie, Authorization

securePublicJson()

JSON response for public data with TTL-based caching.

Public data with cache
import { securePublicJson } from "@workspace/security";

export async function GET() {
  const articles = await getPublicArticles();

  return securePublicJson(articles, {
    maxAge: 300 // Cache for 5 minutes
  });
}

maxAge Options

  • 60 - 1 minute cache
  • 300 - 5 minute cache (default)
  • 3600 - 1 hour cache

Applied Headers

  • Cache-Control: public, max-age={n}, s-maxage={n}, stale-while-revalidate={n/2}
  • Vary: Accept, Accept-Encoding, Origin

Public Data Only

Only use securePublicJson() for data available to all users. User-specific data requires secureUserJson() to prevent cache poisoning.

secureErrorJson()

Error responses with appropriate cache prevention.

Error handling
import { secureErrorJson } from "@workspace/security";

export async function POST(request: Request) {
  try {
    const data = await request.json();
    // Process...
  } catch (error) {
    return secureErrorJson(
      {
        message: "Invalid request data",
        code: "VALIDATION_ERROR",
        issues: ["Field 'email' is required"]
      },
      { status: 400, userScoped: true }
    );
  }
}

Error Object Structure

Error type
{
  message: string;
  code: string;
  issues?: string[]; // Optional validation errors
}

Options

  • status - HTTP status code
  • userScoped - Include Vary: Cookie, Authorization headers

HTML Security Functions

withHtmlSecurityHeaders()

Applies security headers to HTML responses.

Secure HTML response
import { withHtmlSecurityHeaders } from "@workspace/security";
import { NextResponse } from "next/server";

export async function GET() {
  const html = await renderPage();

  return withHtmlSecurityHeaders(
    new NextResponse(html, {
      headers: { "Content-Type": "text/html; charset=utf-8" }
    })
  );
}

Applied Headers

  • X-Content-Type-Options: nosniff

CSP Override

Custom CSP
import { withHtmlSecurityHeaders, CSPBuilder } from "@workspace/security";

const csp = new CSPBuilder()
  .add("script-src", ["'self'", "https://js.stripe.com"])
  .add("frame-src", ["'self'", "https://js.stripe.com"])
  .toString();

return withHtmlSecurityHeaders(response, { cspOverride: csp });

CSPBuilder

Fluent API for building Content Security Policy directives.

CSP builder pattern
import { CSPBuilder } from "@workspace/security";

const policy = new CSPBuilder()
  .add("script-src", ["'self'", "https://trusted-cdn.com"])
  .add("style-src", ["'self'", "https://fonts.googleapis.com"])
  .add("img-src", ["'self'", "data:", "https:"])
  .toString();

// Result:
// "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; script-src 'self' https://trusted-cdn.com; ..."

Default Directives

  • default-src 'self'
  • frame-ancestors 'none'
  • base-uri 'self'

CSP Extension Pattern

Add only required sources to CSP. The builder includes secure defaults. Avoid 'unsafe-inline' and 'unsafe-eval' unless absolutely necessary.

Utility Functions

isStaticAssetPath()

Identifies static asset requests for middleware optimization.

Middleware usage
import { isStaticAssetPath } from "@workspace/security";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Skip cache headers for static assets
  if (!isStaticAssetPath(request.nextUrl.pathname)) {
    response.headers.set("Cache-Control", "no-store");
  }

  return response;
}

Detected Paths

  • /_next/static/*
  • /_next/image/*
  • /_next/data/*
  • /assets/*
  • /favicon.ico
  • Files with static extensions (.js, .css, .png, .woff2, etc.)

Cache Policy Types

CachePolicy type
type CachePolicy =
  | "none"             // No Cache-Control header
  | "private-no-store" // User data - no caching
  | "public-60"        // Public data - 1 min TTL
  | "public-300"       // Public data - 5 min TTL
  | "public-3600";     // Public data - 1 hour TTL

Cache headers generated per policy:

Usage Patterns

API Route Protection

Authenticated endpoint
import { headers } from "next/headers";
import { auth } from "@workspace/auth/server";
import { secureUserJson, secureErrorJson } from "@workspace/security";

export async function GET() {
  const session = await auth.api.getSession({
    headers: await headers()
  });

  if (!session?.user?.id) {
    return secureErrorJson(
      { message: "Unauthorized", code: "UNAUTHORIZED" },
      { status: 401, userScoped: true }
    );
  }

  const data = await fetchUserData(session.user.id);
  return secureUserJson(data);
}

Public Data Endpoint

Cacheable public data
import { securePublicJson } from "@workspace/security";

export async function GET() {
  const posts = await getPublicBlogPosts();

  return securePublicJson(posts, {
    maxAge: 300, // 5 minutes
    additionalVary: ["Accept-Language"] // Content negotiation
  });
}

Error Responses

Consistent error handling
import { secureErrorJson } from "@workspace/security";
import { z } from "zod";

const schema = z.object({
  email: z.string().email(),
  message: z.string().min(10)
});

export async function POST(request: Request) {
  const body = await request.json();
  const result = schema.safeParse(body);

  if (!result.success) {
    return secureErrorJson(
      {
        message: "Validation failed",
        code: "VALIDATION_ERROR",
        issues: result.error.issues.map(i => i.message)
      },
      { status: 400 }
    );
  }

  // Process valid data...
}

Security Considerations

Server-Only Enforcement

The package throws errors when imported in client code:

Browser guard
if (
  typeof globalThis.window !== "undefined" &&
  process.env.NODE_ENV !== "test" &&
  process.env.STORYBOOK !== "true"
) {
  throw new Error(
    "@workspace/security must only be imported on the server."
  );
}

This prevents accidental exposure of security utilities in browser bundles.

Cache Poisoning Prevention

User data requires secureUserJson() with Vary: Cookie, Authorization:

Correct cache boundaries
// ❌ Wrong - user data with public cache
export async function GET() {
  const userData = await getCurrentUser();
  return securePublicJson(userData); // Cache poisoning risk!
}

// ✅ Correct - user data with private cache
export async function GET() {
  const userData = await getCurrentUser();
  return secureUserJson(userData);
}

Without proper Vary headers, CDNs may cache user-specific data and serve it to other users.

CSP Maintenance

Extend CSP incrementally for third-party integrations:

Third-party integration
import { CSPBuilder } from "@workspace/security";

// Analytics integration
const csp = new CSPBuilder()
  .add("script-src", ["https://www.googletagmanager.com"])
  .add("img-src", ["https://www.google-analytics.com"])
  .add("connect-src", ["https://www.google-analytics.com"])
  .toString();

Document required CSP extensions in code comments or configuration files.

Integration with Middleware

Middleware applies headers globally:

apps/dashboard/middleware.ts
import { isStaticAssetPath } from "@workspace/security";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Security headers for all non-static responses
  response.headers.set("X-Content-Type-Options", "nosniff");
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
  response.headers.set("X-DNS-Prefetch-Control", "off");

  // Cache control for dynamic content
  if (!isStaticAssetPath(request.nextUrl.pathname)) {
    response.headers.set("Cache-Control", "no-store");
  }

  return response;
}

API routes supplement middleware headers with response-specific controls via secureJson() functions.

Testing

Mock security functions in tests:

Test mocks
import { vi } from "vitest";

vi.mock("@workspace/security", () => ({
  secureUserJson: vi.fn((body, opts) =>
    Response.json(body, { status: opts?.status ?? 200 })
  ),
  secureErrorJson: vi.fn((error, opts) =>
    Response.json({ error }, { status: opts?.status ?? 500 })
  ),
}));

Integration tests verify actual header application:

Header assertions
import { expect, test } from "vitest";

test("applies security headers", async () => {
  const response = await fetch("/api/protected");

  expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
  expect(response.headers.get("Cache-Control")).toContain("no-store");
  expect(response.headers.get("Vary")).toContain("Cookie");
});

AI-Assisted Security

AI assistants apply security functions automatically:

Secure by Default

When requesting API route implementations, AI assistants select appropriate security functions based on data sensitivity. Authenticated endpoints use secureUserJson(), public endpoints use securePublicJson(), and errors use secureErrorJson().

AI-generated endpoint
// Prompt: "Create API endpoint for user preferences"
import { headers } from "next/headers";
import { auth } from "@workspace/auth/server";
import { secureUserJson, secureErrorJson } from "@workspace/security";

export async function GET() {
  const session = await auth.api.getSession({
    headers: await headers()
  });

  if (!session?.user?.id) {
    return secureErrorJson(
      { message: "Unauthorized", code: "UNAUTHORIZED" },
      { status: 401, userScoped: true }
    );
  }

  const preferences = await getUserPreferences(session.user.id);
  return secureUserJson(preferences);
}

The AI applies authentication checks, appropriate response functions, and error handling patterns consistently.