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
- The Route Handler Security Pattern
- Authentication
- Authorization & Object-Level Access
- Never Trust the Client
- SQL Injection Defense
- Rate Limiting
- Secret Encryption
- License Verification
- API Documentation Protection
- Cron Protection
- Transport, Cookies & Headers
- Production Hardening Checklist
- Security-Relevant Environment Variables
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.
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:
// 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:
// 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:
JWT_SECRET=replace-with-a-long-random-string
JWT_EXPIRES_IN=7d
Rotating
JWT_SECRETinvalidates every existingadmin_tokenandmember_sessionat 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:
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:
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:
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.tscontains aproxy()helper, but the project ships nomiddleware.tswiring 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:
-- 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 andserverErr(e)for unexpected ones (logs internally, returns a generic 500). Stack traces and secrets never reach the client.
// 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:
// 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:
// 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:
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 toJWT_SECRET, then a dev-only default. In production a realSECRET_KEY(orJWT_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)>.
// 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_KEYbefore 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:
// 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()readsx-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.envfile 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_KEYbefore 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:actionpermissions each role needs; reserve super-admin / Administrator for trusted operators. - Confirm the reverse proxy sets trustworthy
x-forwarded-for/x-real-ipheaders for rate limiting. - Keep
DATABASE_URLand every.envfile 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