Security
Security
This page documents the security controls implemented in the SONAN DIGITAL CRM, known vulnerabilities that have been fixed, accepted risks for v1.0, and recommendations for future hardening.
1. Security Headers
Security headers are configured in next.config.ts and applied to all responses via Vercel's Edge Network.
// next.config.ts
const securityHeaders = [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://js.stripe.com https://*.sentry.io",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https://*.supabase.co",
"connect-src 'self' https://*.supabase.co https://api.stripe.com https://*.sentry.io",
"frame-src https://js.stripe.com",
"font-src 'self'",
].join('; '),
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
]
const nextConfig = {
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
]
},
}
'unsafe-inline' is required for Stripe Elements and Next.js inline styles. A nonce-based CSP would be more secure and is recommended for v1.1.
2. Auth Security
httpOnly Cookies
Session tokens are stored exclusively in httpOnly cookies. This prevents client-side JavaScript (including third-party scripts) from accessing the session token, mitigating XSS token theft.
// @supabase/ssr handles cookie configuration
// Cookies are set with:
// - httpOnly: true
// - secure: true (HTTPS only)
// - sameSite: 'lax' (CSRF protection)
// - path: '/'
CSRF Protection
The SameSite=Lax cookie attribute provides CSRF protection for most browser contexts. Mutating API requests (POST, PUT, DELETE) require an authenticated session from the same origin. No additional CSRF tokens are used because the httpOnly+SameSite combination prevents cross-site cookie sending.
Session Rotation on Login
Supabase Auth issues a new session (new access token + refresh token) on each successful login, invalidating any previous tokens. This prevents session fixation attacks.
Password Policy
Supabase Auth enforces: - Minimum 8 characters - No maximum length (passwords are hashed with bcrypt)
Additional complexity requirements can be configured in the Supabase Auth settings. Current setting: minimum length only.
3. Rate Limiting
This is an accepted risk (see ยง9 โ Accepted Risks). The following design is targeted for v1.1.
Recommended approach: Vercel Edge Middleware rate limiting using an in-memory store (e.g., Upstash Redis via @upstash/ratelimit).
// Proposed middleware rate limit (not yet implemented)
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 requests per minute
})
// In middleware:
const { success } = await ratelimit.limit(ip)
if (!success) {
return new NextResponse('Too Many Requests', { status: 429 })
}
Suggested limits:
| Endpoint | Limit |
|---|---|
POST /auth/login |
10 req/min per IP |
POST /auth/mfa/verify |
5 req/min per IP |
POST /api/portal/support-tickets |
20 req/min per client |
| All admin API routes | 300 req/min per user |
4. File Upload Validation
All file uploads go through server-side validation before the upload URL is issued.
Size limit
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024 // 20 MB
if (requestedSize > MAX_FILE_SIZE_BYTES) {
return NextResponse.json(
{ error: 'File exceeds the 20 MB size limit' },
{ status: 400 }
)
}
MIME type allowlist
const ALLOWED_MIME_TYPES = new Set([
'application/pdf',
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'text/csv',
])
if (!ALLOWED_MIME_TYPES.has(mimeType)) {
return NextResponse.json(
{ error: `File type '${mimeType}' is not permitted` },
{ status: 400 }
)
}
The MIME type is provided by the client browser. The server validates the claimed type against the allowlist but does not independently detect the file's MIME type from its magic bytes (not feasible in edge runtime without a native library). Supabase Storage policies provide a second layer of enforcement at the bucket level.
5. Stripe Webhook Security
All Stripe webhook events must be verified using the Stripe signature before processing.
// app/api/webhooks/stripe/route.ts
export const runtime = 'edge'
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature')
if (!sig) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
}
// Must read as ArrayBuffer BEFORE any other body consumption
const rawBody = await req.arrayBuffer()
const bodyText = new TextDecoder().decode(rawBody)
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
bodyText,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error('Stripe signature verification failed:', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
// Process verified event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent)
break
// ...
}
return NextResponse.json({ received: true })
}
Without constructEvent(), any attacker can POST a fake payment_intent.succeeded event to mark unpaid invoices as paid. The raw body must be read as ArrayBuffer before any JSON parsing โ stripe.webhooks.constructEvent() requires the raw bytes to verify the HMAC signature.
6. CRON_SECRET Enforcement
All cron endpoints require a bearer token matching CRON_SECRET. This prevents unauthorized actors (or Vercel users who know the cron URL) from triggering cron jobs manually.
export async function POST(req: Request) {
const authorization = req.headers.get('Authorization')
const expectedToken = `Bearer ${process.env.CRON_SECRET}`
if (!authorization || authorization !== expectedToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// ... cron logic
}
CRON_SECRET should be a randomly generated 32+ character string. Rotate it by updating the Vercel environment variable and redeploying.
7. RLS as Defense-in-Depth
Supabase Row Level Security is active on all tables. It provides a database-level safety net that catches data leaks even when the application layer has a bug.
See RLS for the full policy inventory, known incidents, and testing procedures.
8. Known Security Fixes Applied in v1.0
The following vulnerabilities were identified during internal audit and penetration testing and fixed before the v1.0 release.
CRIT-4: Cross-Tenant Notification Leak
Discovered: Internal security audit, v0.8
Fixed: v0.9
CVSS (estimated): 7.5 (High)
Description: Broadcast notifications (user_id IS NULL) were visible to all users across all tenants due to a missing tenant_id check in the RLS policy on the notifications table.
Fix: Added tenant_id = get_active_tenant_id() to the notifications read policy. See RLS Incidents.
CRIT-7: Client Document Access via Direct Storage URL
Discovered: External penetration test, v0.9
Fixed: v1.0
CVSS (estimated): 8.1 (High)
Description: The documents Storage bucket was configured with public read access. A client who obtained a file_url (visible in API responses) could download documents belonging to other clients via a direct URL, bypassing the RLS-protected documents API.
Fix:
- Changed documents bucket to private
- Portal document list API no longer returns file_url; generates short-lived signed URLs instead
- Added Storage RLS policy requiring tenant_id/client_id path prefix match
See RLS Incidents.
H8: Employee Cross-Employee Time Log Read
Discovered: Internal code review, v0.9
Fixed: v1.0
CVSS (estimated): 4.3 (Medium)
Description: The time_logs RLS policy for employees only enforced tenant_id isolation โ employees could read all time logs across the entire tenant, including colleagues' hours and notes.
Fix: Added employee_id = auth.uid() to the employee SELECT policy on time_logs. See RLS Incidents.
9. Accepted Risks for v1.0
The following issues have been identified but accepted for v1.0 due to scope constraints. They are scheduled for remediation in v1.1 or v1.2.
| ID | Severity | Description | Planned Fix | Target Version |
|---|---|---|---|---|
| HIGH-1 | High | No rate limiting on auth endpoints โ brute-force password attacks possible | Implement Upstash Redis rate limiting in Edge Middleware | v1.1 |
| HIGH-2 | High | No account lockout after repeated failed login attempts | Implement lockout via Supabase Auth hooks | v1.1 |
| HIGH-3 | High | CSP uses 'unsafe-inline' โ XSS mitigations weakened |
Migrate to nonce-based CSP | v1.2 |
| HIGH-4 | Medium | No audit log for admin actions (who changed what, when) | Implement audit_logs table with trigger-based logging |
v1.2 |
| HIGH-5 | Medium | File upload MIME type not validated from magic bytes โ malicious files can be uploaded with trusted MIME types | Add server-side file type detection (requires WASM library in edge) | v1.2 |
| HIGH-6 | Medium | No IP-based anomaly detection โ credential stuffing from distributed IPs not detected | Integrate Cloudflare WAF or similar | v1.2 |
| HIGH-7 | Low | Password reset tokens are not invalidated on password change (Supabase default behavior) | Configure Supabase Auth to invalidate all sessions on password change | v1.1 |
These risks have been reviewed by the engineering lead and accepted for v1.0 given the current threat model (small team of trusted admins, no regulated data). As the platform scales or onboards regulated clients, these must be addressed before that onboarding.
10. Dependency Security
npm audit
Run npm audit regularly to check for known vulnerabilities in dependencies:
npm audit
npm audit fix # auto-fix non-breaking updates
npm audit fix --force # fix with potentially breaking updates (review output carefully)
Vercel runs npm audit during builds and reports critical vulnerabilities in the build log.
Dependabot (Recommended)
Enable GitHub Dependabot for automated dependency update PRs:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"] # Major updates require manual review
Key dependencies and security notes
| Package | Version | Security note |
|---|---|---|
@supabase/ssr |
latest | Must stay updated โ auth cookie handling bugs are critical |
stripe |
latest | Update promptly when Stripe releases security patches |
next |
15.x | Update for security patches; major version upgrades require testing |
@sentry/nextjs |
latest | Source map upload feature requires matching SDK version |
Authentication
The CRM uses Supabase Auth as the identity provider. Sessions are managed via httpOnly cookies using @supabase/ssr. There are two separate auth flows: one for admin/employee users, and one for client portal users.
1. Auth Provider
| Attribute | Value |
|---|---|
| Provider | Supabase Auth (built on GoTrue) |
| Methods | Email + password, TOTP MFA |
| Session transport | httpOnly cookies (sb-access-token, sb-refresh-token) |
| No | OAuth providers, magic links (except password reset), SMS/phone |
2. Session Management
Sessions use @supabase/ssr which sets and reads session tokens from httpOnly cookies on every SSR request. This approach:
- Prevents XSS attacks from accessing tokens (httpOnly prevents JavaScript access)
- Avoids the Next.js hydration issues associated with
localStorage-based auth - Works seamlessly with edge runtime (no Node.js session stores)
Cookie names
| Cookie | Purpose | Expiry |
|---|---|---|
sb-access-token |
Supabase JWT for the current user | 1 hour |
sb-refresh-token |
Token used to obtain a new access token | 7 days |
Token refresh
The @supabase/ssr client automatically refreshes the access token using the refresh token before it expires, as long as the user has an active browser session. Refresh happens server-side on the next SSR request.
Session expiry behavior:
- Access token expires (1 hour inactive): next request silently refreshes using refresh token
- Refresh token expires (7 days): user is redirected to
/auth/login
3. Admin Auth Flow
This flow applies to users with role = admin or role = employee.
User โ POST /auth/login (email + password)
โ
Supabase Auth validates credentials
โ
Check if user has TOTP factor enrolled
โ (yes) โ (no)
Redirect to Set session cookies
/auth/mfa Redirect to /admin/dashboard
โ
User enters 6-digit TOTP code
โ
POST /auth/mfa/verify
โ
Supabase verifies TOTP
โ
Set session cookies
Redirect to /admin/dashboard
Login handler
// app/auth/login/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
export async function login(email: string, password: string) {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) return { error: error.message }
// Check MFA
const { data: factors } = await supabase.auth.mfa.listFactors()
if (factors?.totp && factors.totp.length > 0) {
return { requiresMfa: true }
}
return { success: true }
}
MFA verification handler
// app/auth/mfa/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
export async function verifyMfa(factorId: string, code: string) {
const supabase = await createClient()
const { data: challenge } = await supabase.auth.mfa.challenge({ factorId })
if (!challenge) return { error: 'Could not create MFA challenge' }
const { error } = await supabase.auth.mfa.verify({
factorId,
challengeId: challenge.id,
code,
})
if (error) return { error: 'Invalid code. Please try again.' }
return { success: true }
}
4. Portal Auth Flow
This flow applies to role = client users accessing /portal/*.
Admin sends portal invitation
โ
POST /api/admin/clients/[id]/invite
โ supabase.auth.admin.inviteUserByEmail()
โ Resend invitation email with set-password link
โ
Client clicks link โ /portal/accept-invite
โ
Client sets password
โ
Optional: enroll TOTP MFA
โ
Redirect to /portal/dashboard
Invitation handler
// api/admin/clients/[id]/invite/route.ts
export const runtime = 'edge'
export async function POST(req: Request, { params }: { params: { id: string } }) {
const caller = await requireAdminWithTenant()
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const supabase = createServiceClient()
// Fetch client to get email
const { data: client } = await supabase
.from('clients')
.select('email, name')
.eq('id', params.id)
.eq('tenant_id', caller.tenantId)
.single()
if (!client) return NextResponse.json({ error: 'Client not found' }, { status: 404 })
// Send Supabase invitation (sets role in user_metadata)
const { error } = await supabase.auth.admin.inviteUserByEmail(client.email, {
data: {
role: 'client',
tenant_id: caller.tenantId,
client_id: params.id,
},
redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/portal/accept-invite`,
})
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
// Enable portal access on client record
await supabase
.from('clients')
.update({ portal_access: true })
.eq('id', params.id)
return NextResponse.json({ ok: true })
}
5. requireAdminWithTenant() Pattern
This function is the primary auth guard for every admin API route. It:
- Creates a Supabase client from the request cookies
- Calls
supabase.auth.getUser()to verify the session - Checks that the user's role is
adminorsuper_admin - Returns
{ userId, tenantId }for use in the route handler
// lib/admin-auth.ts
import { createClient } from '@/lib/supabase/server'
export interface AdminCaller {
userId: string
tenantId: string
role: 'admin' | 'super_admin'
}
export async function requireAdminWithTenant(): Promise<AdminCaller | null> {
const supabase = await createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) return null
const role = user.app_metadata?.role as string | undefined
if (role !== 'admin' && role !== 'super_admin') return null
const tenantId = user.app_metadata?.tenant_id as string | undefined
if (!tenantId) return null
return {
userId: user.id,
tenantId,
role: role as 'admin' | 'super_admin',
}
}
Usage in every admin route:
export const runtime = 'edge'
export async function GET(req: Request) {
const caller = await requireAdminWithTenant()
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// caller.userId, caller.tenantId are now safe to use
}
6. getPortalClientId() Pattern
The portal equivalent of requireAdminWithTenant(). Returns the client_id associated with the logged-in portal user, used to scope all portal data queries.
// lib/portal-auth.ts
import { createClient } from '@/lib/supabase/server'
export async function getPortalClientId(): Promise<string | null> {
const supabase = await createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) return null
const role = user.app_metadata?.role
if (role !== 'client') return null
const clientId = user.app_metadata?.client_id as string | undefined
return clientId ?? null
}
7. MFA
| Attribute | Value |
|---|---|
| Method | TOTP only (Time-based One-Time Password, RFC 6238) |
| Authenticator apps | Google Authenticator, Authy, 1Password, any TOTP-compatible app |
| Enforcement | Mandatory for admin and super_admin roles; optional for client role |
| SMS | Not supported |
Enrolling TOTP
// Generate a new TOTP secret and QR code
const { data } = await supabase.auth.mfa.enroll({ factorType: 'totp' })
// data.totp.qr_code โ base64 PNG QR code for authenticator app
// data.totp.secret โ manual entry secret
// data.id โ factorId for verification
// Verify the code to complete enrollment
await supabase.auth.mfa.challengeAndVerify({ factorId: data.id, code: '123456' })
MFA enforcement for admins
The Next.js middleware checks MFA status on every request to /admin/*:
// middleware.ts (simplified)
if (pathname.startsWith('/admin')) {
const aal = session?.user?.app_metadata?.aal
if (aal !== 'aal2') {
// User has not completed MFA โ redirect to MFA challenge
return NextResponse.redirect(new URL('/auth/mfa', req.url))
}
}
aal2 (Authentication Assurance Level 2) is set by Supabase after successful TOTP verification.
8. Password Reset
Password reset uses the Supabase built-in flow (magic link):
- User submits email at
/auth/forgot-password - App calls
supabase.auth.resetPasswordForEmail(email, { redirectTo }) - Supabase sends a password-reset email containing a one-time link
- User clicks link โ
/auth/reset-password?token=... - App calls
supabase.auth.updateUser({ password: newPassword })
Password reset links expire after 1 hour and are single-use. Supabase handles token generation and validation.
9. Session Expiry Summary
| Token | Lifetime | Behavior on expiry |
|---|---|---|
| Access token | 1 hour | Silently refreshed by @supabase/ssr using refresh token |
| Refresh token | 7 days | User redirected to /auth/login |
| MFA challenge | 5 minutes | User must restart MFA flow |
| Invite link | 24 hours | Link expired โ admin must resend invitation |
| Password reset link | 1 hour | Link expired โ user must request again |
Authorization
Authorization in the CRM determines what an authenticated user is permitted to do after their identity has been confirmed. It is implemented via a role-based access control (RBAC) system enforced at three layers: Next.js middleware, API route guards, and Supabase RLS.
1. Role Hierarchy
Roles are stored in users.role and also embedded in the user's Supabase app_metadata JWT claim. The hierarchy is:
super_admin
โโโ admin
โโโ employee
โโโ client
Higher roles do not automatically inherit lower-role permissions โ they have distinct capability sets. A super_admin cannot access the client portal, and a client cannot access any admin route.
Role definitions
| Role | Who | Scope |
|---|---|---|
super_admin |
Platform operators (SONAN DIGITAL staff) | Cross-tenant โ can access and manage all tenants |
admin |
Agency staff with full CRM access | Single tenant โ full access to all CRM features except platform management |
employee |
Agency staff with limited access | Single tenant โ scoped to assigned tasks and own time logs |
client |
End clients using the portal | Single tenant โ portal only, scoped to their own client record |
2. Role Capability Matrix
| Feature | super_admin |
admin |
employee |
client |
|---|---|---|---|---|
| View all tenants | Yes | No | No | No |
| Switch active tenant | Yes | No | No | No |
| Manage team members (invite, role change) | Yes | Yes | No | No |
| View/edit clients | Yes | Yes | Read-only | No |
| View/edit leads | Yes | Yes | Read-only | No |
| Create/send proposals | Yes | Yes | No | No |
| Approve/decline proposals | Yes | Yes | No | Portal only |
| Create/send contracts | Yes | Yes | No | No |
| Sign/decline contracts | Yes | Yes | No | Portal only |
| Create/send invoices | Yes | Yes | No | No |
| Pay invoices (Stripe) | Yes | Yes | No | Portal only |
| Create/manage projects | Yes | Yes | View assigned | No |
| Log time | Yes | Yes | Own tasks only | No |
| View revenue reports | Yes | Yes | No | No |
| View time reports | Yes | Yes | Own logs only | No |
| Upload/manage documents | Yes | Yes | No | Read shared |
| Open/reply support tickets | Yes | Yes | No | Portal only |
| Manage wiki articles | Yes | Yes | No | No |
| Modify billing/Stripe settings | Yes | Yes | No | No |
| Modify tenant settings | Yes | Yes | No | No |
3. Route Guards
Middleware (middleware.ts)
The Next.js middleware runs on every request and enforces session existence for protected route prefixes:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createServerClient } from '@supabase/ssr'
export async function middleware(req: NextRequest) {
const res = NextResponse.next()
const pathname = req.nextUrl.pathname
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { /* ssr cookie helpers */ } }
)
const { data: { session } } = await supabase.auth.getSession()
// Protect admin routes
if (pathname.startsWith('/admin')) {
if (!session) return NextResponse.redirect(new URL('/auth/login', req.url))
const role = session.user.app_metadata?.role
if (role !== 'admin' && role !== 'super_admin') {
return NextResponse.redirect(new URL('/auth/login', req.url))
}
// Enforce MFA for admin users
const aal = session.user.app_metadata?.aal
if (aal !== 'aal2') {
return NextResponse.redirect(new URL('/auth/mfa', req.url))
}
}
// Protect portal routes
if (pathname.startsWith('/portal')) {
if (!session) return NextResponse.redirect(new URL('/portal/login', req.url))
const role = session.user.app_metadata?.role
if (role !== 'client') {
return NextResponse.redirect(new URL('/portal/login', req.url))
}
}
return res
}
export const config = {
matcher: ['/admin/:path*', '/portal/:path*', '/api/admin/:path*', '/api/portal/:path*'],
}
4. Admin Routes (/admin/*)
All routes under /admin/* and /api/admin/* require role admin or super_admin.
At the API level, every handler begins with:
const caller = await requireAdminWithTenant()
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
requireAdminWithTenant() returns null for any user with role employee or client, even if they somehow bypass middleware.
5. Portal Routes (/portal/*)
All routes under /portal/* and /api/portal/* require role client.
At the API level:
const clientId = await getPortalClientId()
if (!clientId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
Every portal query is then additionally filtered by client_id = clientId to prevent a client from accessing another client's data.
6. Employee Access
Employees (role = employee) are a subset of admin users. They have access to:
/admin/projectsโ view projects (read-only)/admin/tasksโ view and update tasks assigned to them/admin/time-logsโ log time on their own tasks; view their own logs only/admin/wikiโ read published wiki articles
Employees are blocked from:
/admin/clientsโ cannot view or edit client records/admin/leadsโ cannot access leads/admin/proposals,/admin/contracts,/admin/invoicesโ billing and sales are admin-only/admin/teamโ cannot invite or manage team members/admin/settingsโ cannot change tenant settings/admin/reportsโ revenue reports are admin-only (time report shows their own data only)
Employee access is enforced at two levels: the middleware requireAdminWithTenant() rejects employees from admin routes, and RLS policies further restrict what rows employees can query.
Some routes under /api/admin/* (e.g., /api/admin/time-logs) are intentionally accessible to employees. These routes have additional role checks inside the handler:
```typescript
if (caller.role === 'employee') {
// Employee can only log time on tasks assigned to them
body.employee_id = caller.userId // override any provided employee_id
}
```
7. getActiveTenantId() Implementation
getActiveTenantId() is used in the application layer when only the tenant ID is needed, without the full admin auth check:
// lib/auth-utils.ts
import { createClient } from '@/lib/supabase/server'
export async function getActiveTenantId(): Promise<string | null> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
return user?.app_metadata?.tenant_id ?? null
}
This is also the function backing the Supabase RLS helper get_active_tenant_id() at the database level (reads from JWT app_metadata.tenant_id).
8. Multi-tenant Isolation
Every query in the application must be scoped to the caller's tenantId. This is enforced at three levels:
- Session:
tenant_idis embedded in the JWTapp_metadataโ users cannot change their own tenant - Application: all queries include
.eq('tenant_id', caller.tenantId)โ defense against RLS misconfiguration - RLS: database policies verify
tenant_id = get_active_tenant_id()โ defense against application-layer bugs
// All admin queries must include tenant scoping
const { data } = await supabase
.from('clients')
.select('*')
.eq('tenant_id', caller.tenantId) // mandatory
.order('created_at', { ascending: false })
9. Super Admin Capabilities
super_admin users can:
- Access all tenants' data via the service client
- Switch the active tenant context for support purposes
- Manage tenant records (create, suspend, configure)
- Impersonate admin users for debugging (must be logged in audit trail)
Super admins authenticate via the same login flow but are redirected to /admin/super after login. Tenant-scoped admin routes remain accessible by setting the X-Tenant-Id header (validated server-side against the super admin role).
10. Common Authorization Errors and Fixes
| Error | Cause | Fix |
|---|---|---|
401 Unauthorized from admin API |
Session cookie missing or expired | User must re-login. Ensure requireAdminWithTenant() is called at route start. |
| Employee gets 401 on time-log route | Route incorrectly calls requireAdminWithTenant() which rejects employee role |
Use a looser guard that allows employee role for specific routes. |
403 on portal route |
getPortalClientId() returns null |
Check that client_id is set in user's app_metadata during invite flow. |
| Supabase returns empty array unexpectedly | RLS policy active, user not matching tenant | Verify JWT app_metadata.tenant_id matches expected tenant. Check policy with SQL editor. |
| Super admin can't access tenant data | Service client not used for cross-tenant operations | Super admin routes must use createServiceClient() not createClient(). |
| New page accessible without login | Route not covered by middleware matcher | Add route prefix to middleware.ts matcher config. |