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¶
Magic Link Flow¶
Canviq uses a custom authentication system (not Supabase Auth). The login flow:
- Request — User enters email on
/auth/login - Magic link — Server generates a time-limited token, stores it in
auth_magic_links, and sends an email via Resend - Verification — User clicks the link, server validates the token at
/auth/verify - Session creation —
createSession(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.
Session Cookie¶
The session is stored in a single cookie:
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:
- AES-256-GCM encryption — Payload is encrypted with a key derived from
SESSION_SECRETviascryptSync. The encrypted output isiv (12 bytes) + authTag (16 bytes) + ciphertext, base64-encoded. - 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:
- Read
feedback_sessioncookie - Verify HMAC signature (constant-time comparison)
- Decrypt AES-256-GCM payload
- Parse
SessionDataJSON - Check
expiresAtagainst current time - Validate
organizationIdandorganizationRoleare present - 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
expiresAtis 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:
revokeUserSessions(userId)— Adds user to deny-listunrevokeUserSessions(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:
- Extract subdomain from
Hostheader (e.g.,acme.canviq.app→acme) - Look up organization by slug via
getOrganizationBySlug() - Set
x-organization-idheader on the response - Generate
x-request-idheader (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¶
- Database Schema — Full table and RLS policy reference
- System Overview — Request flow through the application
- Agent Authentication — Planned API key and scope system