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,
      },
    ]
  },
}
โš ๏ธ
CSP and 'unsafe-inline'

'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

โš ๏ธ
Rate limiting is not implemented in v1.0

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 }
  )
}
โ„น๏ธ
Client MIME types are not trusted

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 })
}
๐Ÿšจ
Never skip signature verification

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
โ„น๏ธ
Risk acceptance

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.

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 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:

  1. Creates a Supabase client from the request cookies
  2. Calls supabase.auth.getUser() to verify the session
  3. Checks that the user's role is admin or super_admin
  4. 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):

  1. User submits email at /auth/forgot-password
  2. App calls supabase.auth.resetPasswordForEmail(email, { redirectTo })
  3. Supabase sends a password-reset email containing a one-time link
  4. User clicks link โ†’ /auth/reset-password?token=...
  5. App calls supabase.auth.updateUser({ password: newPassword })
โ„น๏ธ
Note

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.

โš ๏ธ
requireAdminWithTenant() allows employees by design in some routes

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:

  1. Session: tenant_id is embedded in the JWT app_metadata โ€” users cannot change their own tenant
  2. Application: all queries include .eq('tenant_id', caller.tenantId) โ€” defense against RLS misconfiguration
  3. 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.