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 (
userscollection). Frontend pages are public or tenant-scoped; Better Auth powers customer/dashboard sessions on thecustomerscollection. - Payload Local API — Bypasses access control by default. Every call that passes a
userin this codebase setsoverrideAccess: 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
userscollection. No long-lived tokens on disk;SHIPMORE_API_KEYis 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, Categories —
authenticatedfor write,authenticatedOrPublishedor tenant-scoped for read. - Media — create/update/delete require
authenticated; read isanyone(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 limits —
usershasmaxLoginAttemptsandlockTime. - Max depth —
payload.config.tssetsmaxDepthto 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
corsinpayload.config.tsif 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 }). baseURLis inferred per request — critical for multi-tenant.- Server actions use
zsaprocedures:authProcedure,stripeCustomerProcedure,paywallProcedure(see Auth).
CLI / agent auth
- Operators issue API keys to themselves on the
userscollection (Payload admin → Users → API Key). - The CLI sends
Authorization: Bearer <key>. - Keys never touch disk on the agent’s machine — only
SHIPMORE_API_KEYenv var. - MCP clients use the same key format.
Cookie scoping (multi-tenant)
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
- Validation —
src/env.tsvalidates 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. - Cron —
CRON_SECRET(when configured) protects scheduled jobs invoked from external schedulers.
Optional next steps
- Middleware — add
middleware.tsto redirect unauthenticated users away from/dashboardand authenticated users away from/auth. - Rate limiting — add rate limiting (e.g. Upstash) for public mutations (newsletter, lead forms, generation calls).
- Security headers —
X-Frame-Options,X-Content-Type-Options, optional CSP innext.config.ts. - MFA / SSO — Better Auth supports passkeys; layer SSO providers as needed.