Security Guide

Tours is a single Next.js 16 (App Router) application backed by PostgreSQL — there is no separate API server. Every protected action runs inside a route handler under /api/v1, and each handler enforces its own authentication and authorization. This is a deliberate design choice for a white-label marketplace: the security boundary is the route, not an easily-misconfigured edge layer. This guide documents the mechanisms that actually ship in the source you received and how to harden a production install.

Table of Contents

Security Model at a Glance

Tours serves five audiences from one codebase: the public site, the customer (traveler) portal, the operator portal, the admin panel, and the influencer portal. Each has its own session and its own trust level. The controls that keep them apart are enforced inside every route handler, in a fixed order.

Layer Control Where it lives
Authentication Signed JWT session cookies (HS256) src/lib/auth.ts, src/lib/customer-auth.ts
Authorization Per-route permission checks + owner-scoped queries src/lib/require-permission.ts, every handler
Input trust Server-side re-validation and price recomputation Each handler, src/lib/booking/*
Injection defense Parameterized sql tagged templates only src/lib/db.ts
Abuse control In-memory sliding-window rate limiter src/lib/rate-limit.ts
Secrets at rest AES-256-GCM encryption of integration credentials src/lib/secrets.ts

The Route Handler Security Pattern

Every route that reads or writes user data follows the same four-step shape. The auth guard is always the first line. Skipping or reordering these steps is the single most common way to introduce a vulnerability, so treat this pattern as mandatory.

TS
import { NextRequest } from "next/server";
import sql from "@/lib/db";
import { ok, err, serverErr } from "@/lib/api-helpers";
import { getCustomerId } from "@/lib/customer-auth";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function POST(req: NextRequest) {
  try {
    const me = await getCustomerId();
    if (!me) return err("unauthorized", 401);          // 1) AUTH FIRST

    const b = await req.json();
    const name = String(b.name ?? "").trim().slice(0, 160);
    if (!name) return err("A name is required.");       // 2) VALIDATE + COERCE

    const [row] = await sql`
      UPDATE listings SET name = ${name}
      WHERE id = ${Number(b.id)} AND owner_id = ${me}   -- 3) SCOPE TO OWNER (no IDOR)
      RETURNING *`;
    if (!row) return err("Not found", 404);
    return ok(row);                                     // 4) SHAPED RESPONSE
  } catch (e) { return serverErr(e); }
}

Only genuinely public routes — login, register, traveler checkout, and payment webhooks — omit the auth guard, and they compensate with rate limiting and server-side validation.

Authentication

Tours uses stateless JWTs signed with HS256. Tokens are created and verified with the jose library (src/lib/auth.ts, src/lib/customer-auth.ts); the license client additionally uses offline RS256 verification (see License Verification). There is no refresh token — when a token expires the client re-authenticates.

Session audiences

There are two independent session audiences, each in its own HttpOnly cookie:

Audience Cookie Algorithm Lifetime Source
Admin / staff admin_token HS256 JWT_EXPIRES_IN (default 7d) src/lib/auth.ts
Traveler / operator member_session HS256 30d (1d for short-lived flows) src/lib/customer-auth.ts

Both are signed with the same JWT_SECRET. The verifier pins the algorithm so a forged token cannot downgrade to none or a weaker algorithm:

TS
// src/lib/auth.ts
const { payload } = await jwtVerify(token, secret, { algorithms: ["HS256"] });

Native / Expo clients may send the traveler session as Authorization: Bearer <jwt> instead of the cookie; the same JWT_SECRET verifies either form.

JWT_SECRET fails closed

The signing secret is read from the environment at runtime (lazily, so the install wizard can write it before first use). In production a missing secret is a hard error — the app refuses to sign or verify sessions with predictable bytes:

TS
// src/lib/jwt-secret.ts
if (!value) {
  if (process.env.NODE_ENV === "production") {
    throw new Error(`[auth] ${name} is not set — refusing to sign JWTs with a predictable key.`);
  }
  value = "dev-insecure-jwt-secret-change-me"; // dev only
}

Set a long, random value in production:

ENV
JWT_SECRET=replace-with-a-long-random-string
JWT_EXPIRES_IN=7d

Rotating JWT_SECRET invalidates every existing admin_token and member_session at once, forcing all users to log in again — the fastest way to revoke all sessions if a leak is suspected.

Password hashing

Passwords are hashed with bcryptjs at cost factor 10 before storage and only ever compared with bcrypt.compare — plaintext is never persisted. This applies uniformly across admin login, the traveler/operator portal, password changes, password resets, and the install wizard:

TS
const hash = await bcrypt.hash(password, 10); // on register / reset
const valid = await bcrypt.compare(password, user.password); // on login

auth-schema.ts generates a random admin password in production when ADMIN_PASSWORD is unset — the product never ships a default admin password.

Authorization & Object-Level Access

Authentication proves who is calling; authorization decides what they may touch. Tours enforces both a role-based check for admin actions and an owner-scoped query for every record.

Role-based permissions (admin/staff)

Admin/staff actions are gated per route by requirePermission(action, resource) from src/lib/require-permission.ts. Each protected handler calls it at the top and returns early on denial:

TS
const denied = await requirePermission("update", "tours");
if (denied) return denied; // 403 Forbidden

requirePermission resolves the caller's effective permissions from the admin_token session, the user's role/role_id, and any custom role row in roles:

TS
const isSuper =
  roleId == null ||                          // no custom role → full access
  roleIsSystem ||                            // system role (e.g. Administrator)
  roleName?.toLowerCase() === "administrator" ||
  SUPER_ROLE_STRINGS.has(roleStr);           // VENDOR_ADMIN / SUPER / SUPERADMIN
Action Permission string checked
read <resource>:read
create <resource>:create
update <resource>:update
delete <resource>:delete

A super-admin passes every check. Any other user must hold the exact resource:action string in their role's permission list, or the route returns 403 Forbidden.

The enforced boundary is the handler, not middleware. src/proxy.ts contains a proxy() helper, but the project ships no middleware.ts wiring it in globally. Treat every admin route handler as self-guarding — never assume an upstream layer already checked the session.

Object-level authorization — no IDOR

Passing the role check is not enough. Every fetch or mutation by id must also be scoped to its owner, so one operator can never read or write another operator's data by guessing an id:

SQL
-- customer-owned records
... WHERE id = ${id} AND customer_id = ${me}
-- operator-owned records (tours = listings, operators = hosts)
... WHERE id = ${id} AND owner_id = ${operatorId}

Because ownership is part of the WHERE clause, a request for a record the caller does not own simply returns no row — surfaced as a 404, never someone else's data. Prefer opaque public_id values over serial ids in URLs so ids are not enumerable. Operator routes additionally verify the operator is approved before returning data.

Audit emission

For mutating admin actions (create/update/delete), requirePermission emits an admin.action event via the plugin hook bus (emit("admin.action", { actorId, action, resource })). Emission is best-effort and isolated — it never throws and never blocks the request, so an audit plugin can subscribe without affecting the request path.

Never Trust the Client

Client-side validation exists only for UX. The server re-validates everything and, critically, never trusts a client-supplied amount.

  • Recompute money server-side. Prices, totals, deposits, and coupon effects are computed from the database (src/lib/booking/*), never read from the request. The client may choose options — deposit vs. full payment, which departure, which extras — but never the numbers. A tampered price in the request body is ignored.
  • Coerce and bound every field. Strings are length-capped (String(x).trim().slice(0, n)), numbers are clamped (Math.max/Math.min), enums are checked against allow-lists, and dates are ordered (checkout after checkin, 18+ where relevant).
  • Shape errors. Handlers return JSON via err(message, status) for expected failures and serverErr(e) for unexpected ones (logs internally, returns a generic 500). Stack traces and secrets never reach the client.
TS
// The client may pick a payment option; the server sets the amount.
const quote = await priceBooking(unitId, travelers, extras); // authoritative, from DB
const amount = b.payOption === "deposit" ? quote.deposit : quote.total;

SQL Injection Defense

All database access goes through the tagged sql template and the rawSql(text, params) helper (src/lib/db.ts). Interpolated values are always bound as query parameters — never concatenated into the SQL string:

TS
// SAFE — ${id} is a bound parameter
const rows = await sql`SELECT * FROM listings WHERE id = ${id}`;
// SAFE — positional params
const rows = await rawSql("SELECT * FROM units WHERE listing_id = $1", [listingId]);

Never string-concatenate user input into SQL, and never build dynamic table or column names from user input. React escapes interpolated output by default, covering the reflected-XSS path for rendered content.

The admin backup export (src/app/api/v1/admin/backup) builds a literal SQL dump by escaping values itself. That path runs only for an authorized admin and produces a download, not a live query, so it is not part of the request/response data path.

Rate Limiting

Authentication and public write endpoints are rate-limited via @/lib/rate-limit to blunt credential stuffing and abuse. It is a sliding-window, in-memory limiter — perfect for the single-node deployment this product ships as:

TS
// src/lib/rate-limit.ts
export function rateLimit(key: string, limit: number, windowMs: number): { ok: boolean; retryAfter: number }
export function clientIp(req: Request): string // best-effort IP from x-forwarded-for / x-real-ip

Typical usage keys on the client IP plus the action:

TS
const ip = clientIp(req);
const { ok: allowed, retryAfter } = rateLimit(`login:${ip}`, 10, 60_000);
if (!allowed) return err(`Too many attempts. Try again in ${retryAfter}s.`, 429);

Limiter state lives in the process (a pruned Map), so each instance enforces its own budget. For a multi-instance or serverless-at-scale deployment, swap the Map for Upstash/Redis behind the same function signature — no call sites change.

Secret Encryption

Third-party integration credentials — storage keys, payment-gateway keys, mail credentials — are stored encrypted at rest in the integration_connections table, not in environment variables. src/lib/secrets.ts implements AES-256-GCM:

  • The key is derived as SHA-256(SECRET_KEY), falling back to JWT_SECRET, then a dev-only default. In production a real SECRET_KEY (or JWT_SECRET) is mandatory — the module throws rather than encrypt under a publicly-known key.
  • Each value gets a fresh random 12-byte IV and a 16-byte GCM auth tag.
  • Ciphertext is stored as enc:v1:<base64(iv | authTag | ciphertext)>.
TS
// src/lib/secrets.ts
export function encryptSecret(plain: string): string  // aes-256-gcm, per-value random IV
export function decryptSecret(value: string): string  // returns "" on tamper/failure

Helpers encryptJson / decryptJson handle whole config objects. The storage layer (src/lib/r2.ts) reads the active storage connection's config through decryptJson, so credentials never sit in plaintext. Secret-looking fields are masked (••••••••) when read back into the admin UI and are write-only — re-saving a form that loaded masked values preserves the stored secret instead of wiping it.

Set a strong, unique SECRET_KEY before entering any integration credentials. Because it derives the encryption key, changing it makes every previously-encrypted credential unreadable — you would have to re-enter them in the admin panel.

License Verification

Tours ships with the CreativeCape license client (src/lib/license/). Verification is primarily offline:

  • Activation binds a purchase code to the domain and returns an RS256 JWT from creative-cape.com.
  • On each request the token is verified offline with a bundled RS256 public key (jwt.verify(token, publicKey, { algorithms: ["RS256"] })) — signature, expiry, and domain binding.
  • A daily heartbeat re-validates against the server in the background (fire-and-forget).
  • If the server is unreachable, the license keeps working through a 14-day grace window (GRACE_MS = 14 * DAY) so a network blip never takes a live site down.
  • The resolved status is cached 60 s in-process for hot public pages.

Development hosts (localhost, 127.0.0.1, *.test, private LAN ranges) bypass licensing entirely and unlock all premium add-ons for building and testing. Enforcement is intentionally scoped to an admin-panel lock (LicenseGate) plus a public-site banner (LicenseBanner) — not a full app block. The matching private key exists only on the license server; the public key is baked in, and LICENSE_PUBLIC_KEY / LICENSE_SERVER_URL may override the defaults (e.g. for key rotation) without a code change.

API Documentation Protection

The admin-gated Scalar API reference at /api/docs/v1 and its spec at /api/openapi.json are admin-only. Both routes are dynamic = "force-dynamic" and check getSession(): the reference page redirects unauthenticated users to /admin/login, and the spec returns 401 without a valid admin cookie — so your API surface can't be scraped publicly. Scalar fetches the spec with the admin cookie.

If you also expose a legacy Basic-auth docs endpoint (API_DOCS_USER / API_DOCS_PASS), never leave the defaults in place — override both in production or the documentation is publicly readable.

Cron Protection

The scheduled task runner (src/app/api/v1/cron) is protected by a shared secret, CRON_SECRET. A request is authorized only if it presents the secret as a bearer header or a query key:

TS
// src/app/api/v1/cron/route.ts
const secret = process.env.CRON_SECRET || "";
if (!secret) return false;                              // unset → endpoint trips closed
if (authorizationHeader === `Bearer ${secret}`) return true;
return searchParams.get("key") === secret;

This matches Vercel Cron (which sends Authorization: Bearer <CRON_SECRET>) and any external scheduler. With no CRON_SECRET set the endpoint refuses every request (fails closed). The cron tasks — delayed review requests, abandoned-cart reminders, expired chat-attachment purge, monthly payouts — are each idempotent and row-bounded.

Transport, Cookies & Headers

  • Serve everything over HTTPS. Session cookies and bearer tokens are only safe in transit under TLS. Cookies are set HttpOnly; SameSite=Lax; Path=/, so JavaScript cannot read them and cross-site POSTs do not carry them.
  • Trust proxy headers deliberately. clientIp() reads x-forwarded-for / x-real-ip; make sure your reverse proxy or platform sets these and strips client-supplied copies, so rate-limit keys reflect real callers.
  • Keep secrets out of the repo. DATABASE_URL, JWT_SECRET, SECRET_KEY, and any .env file are secrets — keep them out of version control and off public hosts.

Production Hardening Checklist

  • Set a long, random JWT_SECRET (rotating it logs everyone out).
  • Set a strong, unique SECRET_KEY before entering any integration credentials.
  • Set a strong CRON_SECRET (without it the cron endpoint is closed; with a weak one it is guessable).
  • Override any legacy API-docs Basic-auth defaults, and keep the Scalar reference admin-only.
  • Serve everything over HTTPS so cookies and bearer tokens are never sent in cleartext.
  • Create least-privilege staff roles — grant only the resource:action permissions each role needs; reserve super-admin / Administrator for trusted operators.
  • Confirm the reverse proxy sets trustworthy x-forwarded-for / x-real-ip headers for rate limiting.
  • Keep DATABASE_URL and every .env file out of version control and off public hosts.
  • Lock down the storage bucket: serve only intended public prefixes; keep credentials in the encrypted DB config, not in code.

Security-Relevant Environment Variables

Variable Purpose
DATABASE_URL PostgreSQL (Neon prod / local dev) connection string — pooled URL for the app
JWT_SECRET Signs/verifies admin_token and member_session JWTs; required in production
JWT_EXPIRES_IN Admin token lifetime (default 7d)
SECRET_KEY Derives the AES-256-GCM key for encrypted integration credentials
CRON_SECRET Authorizes the cron endpoint (unset → closed)
API_DOCS_USER / API_DOCS_PASS Basic auth for any legacy docs endpoint (override the defaults)
LICENSE_SERVER_URL License server base URL (default https://creative-cape.com)
LICENSE_PUBLIC_KEY Optional override of the bundled RS256 public key
R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_DEFAULT_BUCKET, R2_PUBLIC_URL Storage fallback when no encrypted DB storage connection is configured

© CreativeCape Solutions · creative-cape.com · support@creative-cape.com