Skip to content

Authentication Architecture

!!! info "TL;DR" Custom AES-256-GCM encrypted, HMAC-signed session cookies. Magic link login only — no passwords, no OAuth. Middleware sets organization context via subdomain routing. RLS policies enforce authorization at the database level using custom SQL functions.

User Authentication

Canviq uses a custom authentication system (not Supabase Auth). The login flow:

  1. Request — User enters email on /auth/login
  2. Magic link — Server generates a time-limited token, stores it in auth_magic_links, and sends an email via Resend
  3. Verification — User clicks the link, server validates the token at /auth/verify
  4. Session creationcreateSession(email) finds or creates the user, loads organization membership, encrypts session data, and sets a signed cookie

There are no passwords, no OAuth providers, and no Supabase Auth integration.

The session is stored in a single cookie:

Cookie: feedback_session=<encrypted-payload>.<hmac-signature>

Cookie properties:

Property Value
Name feedback_session
httpOnly true
secure true in production
sameSite lax
path /
maxAge 28800 (8 hours)

Session Data

The encrypted payload contains:

interface SessionData {
  userId: string
  email: string
  organizationId: string
  organizationRole: 'owner' | 'admin' | 'member'
  expiresAt: number // Unix timestamp in milliseconds
}

Encryption and Signing

The session cookie uses two layers of protection:

  1. AES-256-GCM encryption — Payload is encrypted with a key derived from SESSION_SECRET via scryptSync. The encrypted output is iv (12 bytes) + authTag (16 bytes) + ciphertext, base64-encoded.
  2. HMAC-SHA256 signature — The encrypted payload is signed, producing <encrypted-base64>.<hex-signature>. Verification uses constant-time comparison (crypto.timingSafeEqual) to prevent timing attacks.

Key derivation uses a fixed salt (session-encryption) since the key is unique per SESSION_SECRET, not per session.

Session Validation

auth() from src/lib/auth.ts validates the session on every request:

  1. Read feedback_session cookie
  2. Verify HMAC signature (constant-time comparison)
  3. Decrypt AES-256-GCM payload
  4. Parse SessionData JSON
  5. Check expiresAt against current time
  6. Validate organizationId and organizationRole are present
  7. Check Redis revocation deny-list

If any step fails, auth() returns null.

Session Rotation

Sessions are automatically rotated when past 50% of their lifetime (4 hours):

  • A new expiresAt is computed
  • The session is re-encrypted and re-signed
  • The cookie is updated transparently
  • If rotation fails (e.g., read-only cookie context in middleware), the original valid session is returned and rotation succeeds on the next mutable request

Session Revocation

Server-side revocation uses a Redis deny-list:

Key:    session:revoked:<userId>
Value:  timestamp
TTL:    8 hours (matches max session lifetime)
  • revokeUserSessions(userId) — Adds user to deny-list
  • unrevokeUserSessions(userId) — Removes user from deny-list
  • If Redis is unavailable, revocation fails open (users are not locked out)

Middleware

Organization Context

The middleware (middleware.ts) sets organization context via subdomain routing:

  1. Extract subdomain from Host header (e.g., acme.canviq.appacme)
  2. Look up organization by slug via getOrganizationBySlug()
  3. Set x-organization-id header on the response
  4. Generate x-request-id header (UUID) for tracing

Localhost bypass: In development (localhost / 127.0.0.1), subdomain routing is skipped entirely.

Root domain bypass: feedback.canviq.app and www.canviq.app skip organization lookup.

!!! note "Edge Runtime limitation" auth() uses Node.js crypto which is not available in Edge Runtime. The middleware does not validate sessions — it only resolves the organization from the subdomain. RLS enforces tenant isolation at the data layer.

Locale Routing

The middleware also handles internationalization via next-intl:

  • localePrefix: 'as-needed' — English URLs omit the prefix
  • Matcher: ['/', '/(en|es|fr|de|ru|pt-BR|ja|it)/:path*']

Authorization Model

Organization Roles

Every user belongs to at least one organization via organization_members:

Role Permissions
owner Full access, can manage organization settings and billing
admin Manage submissions, surveys, team members, moderation
member Submit feedback, vote, comment

The role is embedded in the session cookie as organizationRole.

Route-Level Protection

Route handlers use auth() directly for authentication:

const session = await auth()
if (!session) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

Admin routes additionally check the role:

if (session.user.organizationRole === 'member') {
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}

Row-Level Security (RLS)

Authorization is enforced at the database level using RLS policies. Every table has RLS enabled.

Custom RLS Functions

The schema defines helper functions used across policies:

Function Returns Purpose
current_user_id() uuid Reads x-user-id request header
current_organization_id() uuid Reads x-organization-id request header
is_org_member() boolean User belongs to current org
is_org_admin() boolean User is owner/admin in current org

These functions read request headers passed by PostgREST configuration.

Policy Patterns

Organization-scoped read — Most data is scoped to the current organization:

CREATE POLICY "submissions_org_read" ON submissions
  FOR SELECT USING (
    organization_id = current_organization_id()
  );

Authenticated insert — Users can create resources within their org:

CREATE POLICY "submissions_org_insert" ON submissions
  FOR INSERT WITH CHECK (
    organization_id = current_organization_id()
    AND author_id = current_user_id()
    AND is_org_member()
  );

Owner-only update — Users can only modify their own content:

CREATE POLICY "submissions_org_own_update" ON submissions
  FOR UPDATE USING (
    organization_id = current_organization_id()
    AND author_id = current_user_id()
  );

Admin full access — Admins can manage all resources in their org:

CREATE POLICY "submissions_org_admin_all" ON submissions
  FOR ALL USING (
    organization_id = current_organization_id()
    AND is_org_admin()
  );

Append-only audit log — Anyone can insert, only admins can read:

CREATE POLICY "audit_log_insert" ON audit_log
  FOR INSERT WITH CHECK (true);

CREATE POLICY "audit_log_org_admin_read" ON audit_log
  FOR SELECT USING (
    organization_id = current_organization_id()
    AND is_org_admin()
  );

Multi-Tenancy

Every table includes an organization_id column. RLS policies use current_organization_id() to scope all queries to the current tenant. There is no cross-org data leakage possible at the database level, regardless of application bugs.

Agent Authentication

!!! warning "Not Yet Implemented" The Agent IAM system described in ADR-0019 is planned but not yet built. The tables (agent_identities, agent_api_keys, agent_policies, etc.) do not exist in the current schema. The section below documents the planned design.

The planned approach:

  • API keys (32 bytes, base64url-encoded) hashed with Argon2id
  • Scope-based authorization (surveys:read, surveys:write, etc.)
  • Agent operations use the admin client (bypasses RLS) with scope checks in application code
  • Audit logging of all agent actions

See Agent Authentication for the planned design and ADR-0019 for the architecture decision.

Audit Logging

All security-relevant actions are logged to the audit_log table:

  • User login/logout
  • Admin status changes
  • Submission moderation actions
  • Bulk operations (merge, delete)
  • Organization settings changes

The audit log is append-only (no UPDATE or DELETE policies) and only readable by organization admins via RLS.

What's Next