6. Security
6.1 Authentication & Authorisation
Three-layer RBAC on every protected request:
1. Authentication → is the user logged in? (valid JWT in cookie)
2. Role check → are they staff or admin?
3. Ownership check → do they own this specific resource?- IDOR prevention — UUIDs for all IDs, ownership validated server-side on every request
- JWT stored in HttpOnly cookie — not accessible via JavaScript
middleware.tsguards all/account/*and/checkout/*— redirects to/loginif no valid token
6.2 Rate Limiting
| Endpoint | Limit | Response |
|---|---|---|
POST /auth/*/emailpass (login) | 5 attempts / 15 min / IP | 429 + Retry-After header |
POST /auth/*/reset-password | 3 attempts / hour / IP | 429 + Retry-After header |
GET /store/downloads/:token | 10 attempts / min / IP | 429 + Retry-After header |
POST /webhooks/stripe | Stripe IP whitelist only | 403 for unknown IPs |
Library: express-rate-limit. Account lockout (5 failed logins → 30 min lock) is enforced at the database level via customers.failed_login_attempts + locked_until — not just in memory. This survives server restarts.
6.3 Input Validation
All custom endpoints validate with Zod before any business logic runs:
const acceptInviteSchema = z.object({
first_name: z.string().min(1).max(50),
last_name: z.string().min(1).max(50),
password: z.string().min(8).max(100),
})| Field | Rule |
|---|---|
| Format check + max 255 chars | |
| Password | 8–100 chars |
| Names | 1–50 chars |
| Tokens | UUID format (regex validated) |
| Prices | Positive integers only |
SQL injection is prevented by MikroORM parameterised queries — no raw SQL strings anywhere in the codebase.
6.4 File Upload Security
Upload pipeline:
1. Extension check (.zip only)
2. MIME type check (application/zip)
3. Filename sanitisation (sanitize-filename library)
4. Size limit: 500MB max (multer)
5. Zip bomb check: uncompressed size < 2GB
6. Store at: products/{productId}/{randomUUID()}.zip
└── User never controls the storage pathThe storage key is always server-generated — products/{productId}/{randomUUID()}.zip. A user-supplied filename is sanitised before use as a display name only, never as a storage path.
Malware scanning roadmap:
- Phase 1: Admin-only uploads — low risk, skip scanning
- Phase 2: ClamAV on the server
- Phase 3: VirusTotal API for cloud-based scanning
6.5 Security Headers
Set in next.config.ts:
const securityHeaders = [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' https://js.stripe.com",
"frame-src https://js.stripe.com",
`img-src 'self' ${process.env.NEXT_PUBLIC_R2_URL}`,
].join('; ')
},
]| Header | Protects Against |
|---|---|
X-Frame-Options: DENY | Clickjacking |
X-Content-Type-Options: nosniff | MIME type sniffing |
Strict-Transport-Security | HTTP downgrade attacks |
Content-Security-Policy | XSS, data injection |
Referrer-Policy | Referrer leakage |
Test with securityheaders.com (opens in a new tab) before public launch. Target A or A+.
6.6 CORS & Bot Protection
// medusa-config.ts
module.exports = defineConfig({
projectConfig: {
http: {
storeCors: process.env.STORE_CORS, // https://shop.vespertene.com
adminCors: process.env.ADMIN_CORS, // https://admin.vespertene.com
authCors: process.env.AUTH_CORS, // https://shop.vespertene.com
}
}
})- Never use wildcard (
*) in production CORS config - Cloudflare Bot Fight Mode — free, one-click, blocks known bot IPs
- Phase 2: Cloudflare Turnstile CAPTCHA on login and registration