Security
In one line: Security is layered — Zod validation, Upstash rate limits,
nonce-based CSP, exact-origin CORS, file-type whitelisting, HMAC webhook
verification, DPDP consent, six-role RBAC, and hardened session cookies all work
together so one gap never becomes a breach.
Security is layered. No single control is relied upon on its own — validation,
rate limits, headers, access checks, and consent all work together so that one
gap does not become a breach.
The short version
- Every piece of data sent to the server is checked before it is trusted.
- Visitors cannot hammer the site with requests — each endpoint has a limit.
- The browser is told exactly which scripts and origins are allowed.
- Uploaded files are restricted to safe types and a sensible size.
- Messages from outside services are verified before they are acted on.
- People only see and do what their role allows.
- We ask for consent before collecting analytics or marketing data, as required by India’s Digital Personal Data Protection (DPDP) Act.
Input validation — Zod on every API input
Nothing from a client is trusted until it has been validated. Every API route parses its input through a Zod schema withsafeParse()
before any business logic runs. Schemas live in packages/types, shared between
the client form and the server, so the contract is defined in exactly one place.
Use
safeParse(), never parse(). parse() throws an unstructured error;
safeParse() lets the route return a clean VALIDATION_ERROR with field-level
details. Raw client input must never be used past the API boundary.Rate limiting — Upstash sliding windows
Every API endpoint is rate limited in Next.js middleware using@upstash/ratelimit backed by Upstash
Redis. Limits use a sliding window keyed by ${ip}:${pathname}, and run
before auth checks or business logic so abusive traffic is rejected as early
as possible.
Limits are tuned per endpoint to the work they do:
When a caller exceeds a window, the API returns
429 with the RATE_LIMITED
code and retryable: true. Sensible fallbacks apply to any route not listed
explicitly (for example, other POST /api/* routes default to 10 per minute).
Security headers — CSP with a nonce
The app sets a strict Content-Security-Policy so the browser only executes scripts the server explicitly trusts. Each response carries a per-request nonce, and inline scripts must present that nonce to run — this neutralises most cross-site scripting (XSS) attempts even if malicious markup slips through.The nonce is generated fresh per request (alongside the request ID) and
injected into both the CSP header and the allowed
<script> tags. A static
nonce would defeat the purpose.CORS — exact-origin matching
Cross-origin requests are checked against an exact allowed origin —theroyalglow.in. Wildcards (*) are never used for credentialed endpoints.
Anything from another origin is rejected rather than reflected back.
File uploads — type whitelist and size cap
Uploads (such as customer or content images and PDF documents) are constrained on two axes: Files are stored in Cloudflare R2 and served from a dedicated public CDN host, never executed by the application.Webhook verification — HMAC signatures
Inbound webhooks come from external services (QStash, Brevo, Ably, Meta, AiSensy). Each is verified with an HMAC signature check before its payload is acted on, so a forged request cannot trigger real work.META_CAPI_WEBHOOK_TOKEN
and AISENSY_WEBHOOK_SECRET. See Environment Variables
for the full list.
Output safety — no unsanitised HTML
The codebase does not usedangerouslySetInnerHTML without sanitisation.
This is enforced by the linter (noDangerouslySetInnerHtml is an error in the
Biome + Ultracite config), so risky markup injection cannot reach production
unnoticed.
Consent & privacy — DPDP Act (India)
Royal Glow operates under India’s Digital Personal Data Protection (DPDP) Act. Data is stored on Indian servers and is not shared with third-party organisations. Consent is explicit and granular:- Required at onboarding — agreement to the Privacy Policy.
- Optional — analytics — anonymous usage analytics (PostHog + Clarity) load only when the customer opts in.
- Optional — marketing — offers and promotions (Meta Pixel + CAPI) fire only when the customer opts in.
Consent choices captured at onboarding seed the
rgss_cookie_consent store, so
the cookie banner does not re-ask for categories the customer has already
decided on. See Authentication for the onboarding flow.Access control — six RBAC roles
Authorisation uses Better Auth’s roles plugin with six concrete roles in a strict hierarchy. Each role inherits the access of those below it.
A request that is authenticated but lacks the required role receives
403 with
the FORBIDDEN code — distinct from 401 UNAUTHENTICATED, which means no valid
session at all.
Session cookies — HttpOnly, Secure, SameSite=Lax
Sessions are managed by Better Auth and stored in Neon (PostgreSQL), not in a third-party service. The session cookie is hardened: Because Google OAuth runs on Royal Glow’s own domain, the callback attheroyalglow.in/api/auth/callback/google keeps the session first-party. See
Authentication for the full sign-in design.
Security checklist
Every new API route and feature is held to this list:- Zod validation on every input, via
safeParse() - Per-endpoint
@upstash/ratelimitsliding window applied in middleware - CSP header with a per-request nonce
- CORS restricted to the exact
theroyalglow.inorigin - File uploads limited to
jpg/png/webp/pdf, 10 MB max - HMAC signature verified on all inbound webhooks
- No
dangerouslySetInnerHTMLwithout sanitisation - DPDP consent respected before analytics/marketing load
- Role checked against the six-role hierarchy
- Session cookies set
HttpOnly,Secure,SameSite=Lax