Skip to Content
Security

Security

This page documents the security measures already configured in ShipMore. For Payload-specific hardening, see Payload: Preventing Production API Abuse .


Overview

  • Admin vs. frontend — Admin is protected by Payload auth (users collection). Frontend pages are public or tenant-scoped; Better Auth powers customer/dashboard sessions on the customers collection.
  • Payload Local API — Bypasses access control by default. Every call that passes a user in this codebase sets overrideAccess: false.
  • Secrets — Server-only env validation via @t3-oss/env-nextjs; no client secrets; webhooks verified with signing secrets.
  • CLI auth — API keys on the users collection. No long-lived tokens on disk; SHIPMORE_API_KEY is read from the env var only.

Payload

Access control

Every collection that holds sensitive data has access defined (read, create, update, delete):

  • Tenants, Pages, Posts, Categoriesauthenticated for write, authenticatedOrPublished or tenant-scoped for read.
  • Media — create/update/delete require authenticated; read is anyone (public assets).
  • Users, Customers, Leads, Products, Records, Project-Schemas, Import-Mappings, Observations — authenticated or tenant-scoped as needed.

Access helpers live in src/domains/cms/payload/access/ (authenticated, anyone, authenticatedOrPublished).

Local API: overrideAccess is non-negotiable

The Local API skips access control by default. When passing user to a Payload call, always set overrideAccess: false:

// ✅ enforces user permissions await payload.find({ collection: 'pages', user, overrideAccess: false }) // ❌ access control bypassed — runs with admin privileges await payload.find({ collection: 'pages', user })

For intentional admin operations (scripts, seed data, revalidation hooks), omit user entirely — overrideAccess defaults to true with no user.

Transaction safety in hooks

Always pass req to nested Payload operations inside hooks. Missing req breaks atomicity:

afterChange: [async ({ doc, req }) => { await req.payload.create({ collection: 'audit-log', data: { docId: doc.id }, req }) }]

Hook loop prevention

Use a context flag to break recursion when a hook updates the same collection:

afterChange: [async ({ doc, req, context }) => { if (context.skipHooks) return await req.payload.update({ collection: 'posts', id: doc.id, data: { processedAt: new Date() }, context: { skipHooks: true }, req, }) }]

Preventing abuse

Aligned with Payload: Preventing Production API Abuse :

  • Failed login limitsusers has maxLoginAttempts and lockTime.
  • Max depthpayload.config.ts sets maxDepth to limit relationship depth.
  • GraphQL — disabled (graphQL.disable: true); the app uses Local API + REST + MCP.
  • CSRF — Payload’s cookie-based auth includes CSRF protection.
  • CORS — set cors in payload.config.ts if you call the API from another origin.
  • Uploads — Media writes are authenticated; reads are public. Tighten or add file scanning in hooks if needed.

Auth

Better Auth (frontend customers)

  • Session is read from request headers via payload.betterAuth.api.getSession({ headers }).
  • baseURL is inferred per request — critical for multi-tenant.
  • Server actions use zsa procedures: authProcedure, stripeCustomerProcedure, paywallProcedure (see Auth).

CLI / agent auth

  • Operators issue API keys to themselves on the users collection (Payload admin → Users → API Key).
  • The CLI sends Authorization: Bearer <key>.
  • Keys never touch disk on the agent’s machine — only SHIPMORE_API_KEY env var.
  • MCP clients use the same key format.

Auth cookies are scoped to the domain they were set on. A customer signed in on tenant-a.com is not signed in on tenant-b.com — this is expected browser behavior and provides proper tenant isolation.


Secrets and environment

  • Validationsrc/env.ts validates server and client env with @t3-oss/env-nextjs. Server-only variables (PAYLOAD_SECRET, DATABASE_*, STRIPE_*, RESEND_API_KEY, …) are never exposed to the client.
  • Webhooks — Stripe webhooks verify signatures using STRIPE_WEBHOOKS_ENDPOINT_SECRET; secrets stay server-side.
  • CronCRON_SECRET (when configured) protects scheduled jobs invoked from external schedulers.

Optional next steps

  • Middleware — add middleware.ts to redirect unauthenticated users away from /dashboard and authenticated users away from /auth.
  • Rate limiting — add rate limiting (e.g. Upstash) for public mutations (newsletter, lead forms, generation calls).
  • Security headersX-Frame-Options, X-Content-Type-Options, optional CSP in next.config.ts.
  • MFA / SSO — Better Auth supports passkeys; layer SSO providers as needed.