Security Audit
Transparency report from a comprehensive OWASP-aligned security audit. Share this with enterprise prospects, compliance teams, and security-conscious customers as proof of production-grade posture.
Audit Summary
| Property | Value |
|---|---|
| Scope | Full-stack: frontend, backend, database |
| Standard | OWASP Top 10 (2025) |
| Critical Issues | 0 |
| High Issues | 0 |
| Medium Issues | 0 |
| Low / Informational | 0 actionable |
| Tables Audited | 32 |
| Server Actions | 28 |
| API Routes | 21 (groups) |
| Webhook Endpoints | 5 (one per payment provider) |
| Unit Tests | 900 tests across 84 unit test files |
| E2E Tests | 9 suites (Playwright) |
| Result | PASS — Zero Critical Vulnerabilities |
Authentication Layer
Audit Scope
Every enabled auth pathway: email/password, OAuth (Google/GitHub), magic links, passkeys (WebAuthn), TOTP MFA, full session lifecycle (creation, refresh, destruction).
Findings
| Control | Status | Implementation |
|---|---|---|
| Session management | ✅ | Supabase Auth + PKCE. JWTs with httpOnly, secure, sameSite=lax cookies. |
| Password requirements | ✅ | Zod: min 8 chars. Supabase handles Argon2id hashing. |
| OAuth callback | ✅ | /auth/callback exchanges code server-side. PKCE code_verifier prevents interception. |
| MFA enforcement | ✅ | Universal via proxy.ts. Queries getAuthenticatorAssuranceLevel(). Fail-closed: errors redirect to /login?mfa=error. |
| Passkey (WebAuthn) | ✅ | 60s challenge expiry.credential_id + public_key in passkeys table. Post-login AAL check triggers inline MFA. |
| Session invalidation | ✅ | signOut() after password reset. /verified clears stale sessions. |
| Step-Up Auth | ✅ | High-risk actions (password changes, MFA disablement) require current password re-verification to prevent hijacking. |
| Duplicate email | ✅ | Inspects identities array — generic toast, no account existence leak. |
| Callback errors | ✅ | Parses error, error_code, error_description. Redirects to /login with toast. |
Server Action Auth Pattern
Every Server Action follows this exact sequence:
const user = await requireUser();
const parsed = schema.safeParse(input);
if (!parsed.success) return { success: false, error: "Invalid input" };
const [row] = await db.insert(table).values(values).returning({ id: table.id });
revalidatePath("/dashboard");
revalidateTag(CACHE_TAGS.dashboard, "max");Verified: Server Actions use centralized requireUser() / verifyAdmin() guards before validation. No action trusts client-provided user IDs. No action uses cookies() for auth.
️ Authorization & Access Control
Row-Level Security (RLS)
All 32 tables have RLS enabled with data isolation policies.
| Pattern | SQL |
|---|---|
| User reads own data | USING ((select auth.uid()) = user_id) |
| User writes own data | WITH CHECK ((select auth.uid()) = user_id) |
| Super-Admin override | EXISTS (SELECT 1 FROM profiles WHERE id = (select auth.uid()) AND role = 'super-admin') |
| Org membership | EXISTS (SELECT 1 FROM org_members WHERE org_id = [table].org_id AND user_id = (select auth.uid())) |
| Avatar Upload | ✅ — Resolved storage 403 by migrating api/user/avatar to service_role client (bypassing RLS) while maintaining identity verification via session check. |
Verified: Every table uses (select auth.uid()) subquery form for per-query caching.
Ownership Verification
Every mutation includes ownership gates:
.where(and(
eq(table.id, resourceId),
eq(table.userId, user.id) // Ownership gate
))Verified: No mutation modifies resources without ownership check.
Authenticated AI support conversations also resolve caller-supplied
conversationId values through getConversationForUser() before history reads,
message inserts, broadcasts, status updates, or escalation ticket creation.
Guests continue to use local conversation persistence, while authenticated
conversation IDs are treated as untrusted references until matched to the
effective user.
Super-Admin Protection
- Self-shielding — super-admin accounts cannot be deleted via super-admin UI
- Impersonation — HMAC-signed cookie context, 1-hour auto-expiry, read-time super-admin/target revalidation, audit trail
- Context propagation —
proxy.tsinjectsx-impersonating-*headers for Server Components and audit logging getEffectiveUserId()— read/write isolation during impersonation;PATCH /api/user/profilecorrectly targets the impersonated user context- Layout Synchronization — Reactive CSS variables (
--topbar-height) manage real-time UI reflow across decoupled components (Sidebar, Banner) to eliminate jitter during session transitions - Activity logging — 36 typed
ActivityActioncategories inactivity_logs - Resource Safety & Deletion Guards — Irreversible operations (e.g., deleting projects) require mandatory
AlertDialogconfirmation to prevent accidental data loss. - Resource Capping — Tier-based limits (defined in
lib/config/pricing.ts) are enforced server-side and UI-disabled to prevent resource exhaustion and drive conversions.
️ SSRF Protection (A01 / A05)
lib/security/ssrf.ts — isAllowedUrl() + sanitizeUrl():
- Blocks private IPs (
10.x,172.16-31.x,192.168.x,127.x) - Blocks cloud metadata (
169.254.169.254, Azure IMDS, GCP) - Rejects non-HTTP(S) schemes (
file://,ftp://,gopher://,data:) - Detects numeric IP obfuscation (octal, hex, decimal)
- 25+ unit tests covering all bypass techniques
Verified: User-controlled outbound URL fetches are blocked. Provider-returned Murf audioFile URLs are also passed through sanitizeUrl() before fetch so voice delivery keeps the SSRF boundary active.
Cryptographic Security (A04)
lib/security/crypto.ts — centralized secure random generation:
| Function | Backing | Used In |
|---|---|---|
secureRandomString(length) | crypto.randomBytes() → base36 | Affiliate referral codes, upload paths |
secureRandomHex(length) | crypto.randomBytes() → hex | Dodo webhook event IDs |
Verified: Zero Math.random() in security-sensitive server code. All randomness uses crypto.randomBytes().
Input Validation
Zod on Every Boundary
Every Server Action and API route validates input with Zod before any DB operation.
const schema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
});
const parsed = schema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Invalid input" };
}Verified: No action accepts raw, unvalidated input. No schema uses z.any() or z.unknown() without refinement. API and Server Action responses across user, support, developer, super-admin, checkout, upload, and voice surfaces are sanitized to remove verbose Zod validation error strings and use centralized i18n/domain keys, preventing internal schema structures or provider details from leaking. Guest checkout, passkey login, and account deletion failures return stable user-facing messages while preserving root causes in server logs and Sentry.
Upload signing validates bucket, declared MIME type, and size metadata before issuing a Supabase signed upload URL. Stored object extensions are derived from the validated MIME allowlist, not the client-provided filename, so invoice.html with application/pdf stores as .pdf or fails if no safe mapping exists. Production bucket MIME and size policies remain mandatory server-side enforcement for direct browser-to-storage uploads.
XSS Prevention in Emails
escapeHtml() sanitizes user-provided data (user.fullName) before HTML email template injection.
Webhook Security
HMAC-SHA256 Verification
All 5 providers verify request authenticity:
| Provider | Header(s) | Algorithm |
|---|---|---|
| Stripe | stripe-signature | HMAC-SHA256 |
| Polar | webhook-id + webhook-timestamp + webhook-signature | HMAC-SHA256 |
| Lemon Squeezy | x-signature | HMAC-SHA256 |
| Dodo | webhook-id + webhook-timestamp + webhook-signature | HMAC-SHA256 |
| Creem | x-creem-signature | HMAC-SHA256 |
Verified: No webhook processes payload without signature validation. crypto.timingSafeEqual() for constant-time comparison.
Idempotency
webhook_logs tracks every event by event_id + provider:
- Skip existing
successrows - Skip active
processingrows younger than 5 minutes - Reuse
failedand staleprocessingrows withattempts + 1 - Execute handler only after the row is claimed
- Record success/failure with attempt count
Verified: Duplicate deliveries cause zero duplicate mutations, while transient failures remain retryable.
Billing state adds provider-resource idempotency below the webhook delivery layer:
subscriptions(provider, provider_subscription_id)is unique when present, and subscription sync upserts on that key.purchases(provider, provider_payment_id)is unique when present, and duplicate purchase sync returns the existing purchase ID.- Creem rejects webhook payloads missing a durable
event.id.
Checkout Error Containment
Checkout creation responses return generic translated errors (checkoutCreateError, checkoutSessionError) while provider details are captured server-side. Guest checkout modals display the same stable localized failure copy instead of propagating thrown backend strings. This keeps provider exception text, account identifiers, and SDK diagnostics out of client-visible responses.
Account Deletion Safety
Permanent account deletion is fail-closed for paid users. The deletion service selects every active-like subscription row for the user and cancels each stored provider subscription before local destruction begins. If any provider subscription lacks a provider id, uses an unsupported provider, or fails cancellation, local profile, storage, waitlist, and auth deletion do not proceed. When deletion proceeds, user-owned storage objects are removed from configured buckets, waitlist PII is removed by email, and Supabase Auth deletion drives the profile cascade. The user remains recoverable when any critical external cleanup fails. Unexpected deletion failures are logged with root-cause detail server-side while callers receive stable deletion failure copy.
Transport & Headers
CSP (Content Security Policy)
Nonce-based via proxy.ts:
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://*.supabase.co wss://*.supabase.co;- Per-request cryptographic nonce
strict-dynamicallows trusted script chains- JSON-LD scripts receive the same nonce via
<JsonLd nonce={nonce} /> - Inline styles permitted (Tailwind CSS requirement)
CSRF Protection
HMAC-bound double-submit cookie + future-timestamp guard + 1-hour token expiry:
- Server generates token with embedded timestamp, nonce, and user-bound HMAC →
httpOnlycookie - Client sends via
X-CSRF-Tokenheader (csrfFetch()auto-injection) - Server validates cookie ↔ header match
- Server recomputes the HMAC with the authenticated user ID and rejects forged or cross-user tokens
- Future-timestamp guard rejects clock skew attacks
- Token expiry enforcement — tokens older than 1 hour are rejected (prevents replay)
- Cookie Seeding Protocol: To resolve the Next.js Server Component limitation (which prevents setting cookies during RSC rendering),
proxy.tsdynamically seeds or refreshes the__csrf_tokencookie for authenticated users during page requests, including public marketing and invite routes. Stale or cross-user tokens are rejected and replaced before Server Components read cookies. - Root Client Context:
app/layout.tsxreads the middleware-seeded cookie and wraps the application tree inCSRFProvider, makingcsrfFetch()available to authenticated client components rendered outside(protected). - oRPC Transport Header: The same-origin browser oRPC client reads the CSRF cookie and sends
X-CSRF-Tokenfor RPC calls through/api/rpc.
Verified: All authenticated state-mutating requests are CSRF-protected. Token TTL and HMAC/user binding are enforced in proxy.ts middleware. /api/support/chat is not a global CSRF exemption; only guest chat requests without an authenticated session bypass CSRF so public support remains available. Public authenticated flows such as organization invite acceptance use csrfFetch() and the root CSRF provider.
Security Headers
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options | nosniff | MIME sniffing prevention |
X-Frame-Options | DENY | Clickjacking prevention |
Referrer-Policy | strict-origin-when-cross-origin | Referrer leakage |
Permissions-Policy | Restrictive | Unused browser API lockdown |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | HSTS |
Redirect Security
All checkout and billing portal return URLs must be constructed using buildSameOriginUrl() from @/lib/security/redirects. This explicitly checks the given URL against the allowed application origin and a whitelist of valid return paths, thwarting open-redirect attacks that try to hijack the post-payment flow.
Allowed Origins (Cross-Origin Resource Sharing)
To mitigate Server-Side Request Forgery (SSRF) and restrict unauthorized asset loading, the next.config.ts explicitly defines allowed remote patterns for external resources (e.g., Supabase storage, Google user avatars). Allowed dev origins are strictly configured to prevent malicious domain loading.
Rate Limiting & DDoS
Edge Rate Limiting
Upstash Redis in proxy.ts middleware:
| Tier | Requests/Window | Window |
|---|---|---|
| Free | Configured | Per-tier |
| Starter | Configured | Per-tier |
| Pro | Configured | Per-tier |
Limits defined in lib/config/pricing.ts — single source of truth.
Route-Level API Rate Limiting
Beyond the edge middleware, an exhaustive inline checkRateLimit(request) higher-order function pattern enforces rate limiting at the route handler level.
Verified: Enforced across 56+ public and authenticated API route files, including Super-Admin /api/super-admin/* and User-management /api/user/* endpoints, securing sensitive mutations and query paths from burst abuse.
Circuit Breaker
- Track violation count per IP in Redis
- 5 violations → auto-ban for configurable duration
- Banned IPs get
429without touching app layer
Verified: Rate limiting applied before auth — protects the auth layer itself from brute force.
Graceful Degradation
When Redis is unavailable:
Production rate limiting is enforced even if Redis credentials are missing. In
that configuration the edge limiter attempts Redis and fails closed, and
authenticated health reports Redis as unhealthy so the deployment cannot be
treated as launch-ready. The public /api/health endpoint exposes only minimal
liveness metadata.
Client IP attribution is centralized in getClientIp(). Forwarding headers are
trusted only on Vercel or when TRUST_PROXY_HEADERS=true is set behind an
ingress layer that strips untrusted forwarding headers.
| Rate Limiter | Failure Mode | Rationale |
|---|---|---|
rateLimit() (tier-based) | Fail-open | Availability over strictness for general requests |
edgeRateLimit() (middleware) | Fail-closed | Security-critical path — block unknown traffic |
️ Database Security
Connection Security
- TLS on all connections
- Supabase pooler (port 6543, transaction mode for serverless)
- Drizzle ORM parameterizes all queries — zero SQL injection surface
Sensitive Data
| Data | Protection |
|---|---|
| Passwords | Argon2id (Supabase Auth) — never in app |
| API keys | SHA-256 hashed (key_hash), original shown once |
| Webhook secrets | Env vars only — never in DB or client |
| IP addresses | SHA-256 hashing before affiliate storage (GDPR) |
| PII in analytics | Email excluded from trackSignup(). Third-party analytics scripts/events require explicit analytics consent. Sentry PII and Session Replay are opt-in. |
| Passkey keys | Public key only — private key never leaves device |
Pagination & Memory
List queries are bounded before execution. Cursor-based reads cap at 100 rows per page. Offset-based reads cap at 200 rows. Webhook administration list reads clamp requested limits to 100 and project only the fields required by retry, list, and detail views.
Data Minimization
Backend reads use explicit Drizzle projections instead of bare .select(). Dashboard sales profile reads, webhook log queries, and background subscription/trial reminder profile reads return only the columns consumed by the caller.
Atomic Operations
deduct_credits() uses FOR UPDATE row locking — prevents race conditions in concurrent AI usage.
requestPayout() uses serializable transactions — eliminates TOCTOU race conditions between payout eligibility checks and deduction, ensuring zero data loss during concurrent credit events.
Security Logging (A09)
auth_events table — append-only RLS:
| Event | Trigger | Data |
|---|---|---|
login_success | Successful auth | User ID, IP, method |
login_failure | Failed auth | IP, email, reason |
mfa_challenge | MFA initiated | User ID, factor type |
mfa_failure | MFA failed | User ID, IP |
ip_banned | Circuit breaker | IP, violation count |
brute_force_alert | Threshold breach | IP, attempts, window |
logAuthEvent() records all events. checkAndAlertBruteForce() triggers on sliding window threshold breach.
Security Dashboard — real-time in Super-Admin: stat cards, paginated event table with severity badges, time-range filtering (24h/7d/30d).
Error Handling
Sanitization
- API errors return user-friendly messages — never stack traces or internal paths
- oRPC uses
ORPCErrorwith typed codes and i18n messages - Sentry captures full details server-side; clients see safe messages only
- Session Replay remains disabled unless
NEXT_PUBLIC_SENTRY_REPLAY_ENABLED=true - Error boundaries on route segments prevent full-page crashes
Environment Variables
Verified: No server secrets (SUPABASE_SECRET_KEY, STRIPE_SECRET_KEY, etc.) in client bundles. Only NEXT_PUBLIC_* exposed.
Key Rotation Policy
OWASP A04:2025 — Cryptographic Failures
| Secret | Rotation | Owner | Notes |
|---|---|---|---|
SUPABASE_SECRET_KEY | On compromise | Platform super-admin | Dashboard → Settings → API Keys |
| Stripe API key | Quarterly / compromise | Billing super-admin | Dashboard → Developers → Roll |
| Creem API key | Quarterly / compromise | Billing super-admin | Dashboard → API Settings |
| Polar access token | Quarterly / compromise | Billing super-admin | Dashboard → Settings → API |
| Dodo API key | Quarterly / compromise | Billing super-admin | Dashboard → API Keys |
| LemonSqueezy API key | Quarterly / compromise | Billing super-admin | Dashboard → Settings → API |
| Webhook secrets (×5) | On compromise | Billing super-admin | Update provider + env simultaneously |
NEXT_AUTH_SECRET | On compromise | DevOps | Requires user re-authentication |
CSRF_SECRET | On deploy (recommended) | DevOps | No user impact — sessions valid |
GEMINI_API_KEY | On compromise | AI super-admin | Cloud Console → Credentials |
| Upstash Redis creds | On compromise | Platform super-admin | Upstash Console → Database |
| JWT signing key | Managed by Supabase | N/A | Auto-rotated internally |
Rotation Procedure:
- Generate new secret in provider dashboard
- Update env var in Vercel (production + preview)
- Trigger deployment
- Verify via health check endpoint
- Revoke old key in provider dashboard
- Log rotation in
activity_logs
Testing Coverage
| Suite | Tool | Files | Tests | Coverage |
|---|---|---|---|---|
| Unit | Vitest | 84 | 900 | Actions, utilities, config, payments, auth, security, services |
| E2E: Auth | Playwright | 1 | — | Signup, login, reset, sessions |
| E2E: Super-Admin | Playwright | 1 | — | Users, impersonation, RBAC |
| E2E: Billing | Playwright | 1 | — | Checkout, subscriptions, portal |
| E2E: Checkout | Playwright | 1 | — | Guest checkout, tier selection |
| E2E: Orgs | Playwright | 1 | — | Org CRUD, invitations, members |
| E2E: Account | Playwright | 1 | — | Deletion, cleanup, session termination |
| E2E: MFA | Playwright | 1 | — | TOTP enrollment, QR code, OTP input |
| E2E: Passkeys | Playwright | 1 | — | WebAuthn registration, security tab UI |
| E2E: Example | Playwright | 1 | — | Template for new E2E tests |
CI Pipeline
npm run lint— ESLintnpm run type-check- Application and setup-wizard TypeScript strict gatesnpm run build— Production build- Tests must pass before merge
npm run security:audit— Production dependency scanning (A03)- CycloneDX SBOM generation + GitHub Actions artifact
- Dependabot for automated security PRs
Compliance Readiness
| Framework | Status | Evidence |
|---|---|---|
| OWASP Top 10 | Full | All 10 categories — A01 through A10 |
| GDPR | Partial | PII opt-in analytics, IP anonymization, consent tracking |
| SOC 2 | Foundation | Audit trail, access controls, encryption in transit |
| PCI DSS | Delegated | Payment processing via PCI-compliant providers |
Full GDPR, SOC 2, and PCI DSS compliance requires organizational policies beyond these technical controls.
How to Use This Report
- Enterprise sales — Share with prospects requesting security docs. Zero-critical result + RLS on 32 tables + OWASP compliance addresses procurement requirements.
- Security teams — See THREAT_MODEL.md (STRIDE) · LLM_SECURITY.md · SUPPLY_CHAIN_SECURITY.md
- Compliance — Maps to OWASP Top 10 categories with implementation + verification per section.
- Developers — Run
.agents/workflows/security-audit.mdafter architectural changes.
Copyright © 2026 Vibe44. Built with ❤️ by Scale with Alap.