Authentication Architecture
EnvCat uses Clerk for authentication with a hybrid architecture that combines Next.js BFF (Backend-for-Frontend) and Flask API.
Overview
Browser (Clerk) → Next.js BFF (JWT forwarding) → Flask API (JWT validation)
Key principles:
- All authentication happens via Clerk JWT tokens
- Next.js BFF acts as a thin proxy layer
- Flask API validates JWTs and enforces multi-tenancy
- Single authentication system across all routes
Authentication Flow
1. User Login
┌─────────────┐
│ Browser │
│ (Clerk JS) │
└──────┬──────┘
│ User enters credentials
│
▼
┌──────────────────┐
│ Clerk Servers │
│ (Auth Provider) │
└──────┬───────────┘
│ Returns JWT token
│ (includes user_id, org_id, role)
│
▼
┌─────────────────────┐
│ Next.js Session │
│ (Server-side) │
│ │
│ Stores JWT in │
│ HTTP-only cookie │
└─────────────────────┘
2. API Request Flow
┌─────────────┐
│ Browser │
│ Request │
└──────┬──────┘
│ GET /api/v1/bundles (with session cookie)
│
▼
┌─────────────────────────┐
│ Next.js Middleware │
│ (Clerk Auth Check) │
│ │
│ If not authenticated: │
│ → Redirect to /sign-in │
│ │
│ If authenticated: │
│ → Continue to BFF │
└──────┬──────────────────┘
│
▼
┌─────────────────────────┐
│ Next.js BFF Route │
│ /app/api/v1/bundles/ │
│ route.ts │
│ │
│ requireSession() │
│ └─ Gets Clerk JWT │
│ │
│ flaskFetch() │
│ └─ Forwards: │
│ Authorization: │
│ Bearer <jwt> │
└──────┬──────────────────┘
│ HTTP request to internal Docker network
│
▼
┌───────────────────────────────────┐
│ Flask API (Internal) │
│ http://api:5000 │
│ │
│ @require_org_membership │
│ ├─ Extract JWT from header │
│ ├─ Fetch Clerk JWKS │
│ ├─ Verify signature │
│ ├─ Extract claims: │
│ │ - user_id (Clerk) │
│ │ - org_id (Clerk) │
│ │ - role (admin/member) │
│ ├─ Verify DB membership: │
│ │ JOIN users + memberships │
│ │ (maps Clerk ID to internal) │
│ └─ Set Flask g context: │
│ - g.user_id (Clerk) │
│ - g.org_id (Clerk) │
│ - g.org_role │
│ │
│ Route Handler Executes │
│ - Uses g.org_id for filtering │
│ - Enforces tenant isolation │
└───────────────────────────────────┘
Just-in-Time User Provisioning
When a user logs in for the first time, EnvCat creates their user record and Clerk organization:
Flow
# 1. User signs up in Clerk → JWT issued
# 2. First request to any protected page
# 3. Next.js calls requireSession()
# 4. requireSession() calls /api/v1/users/ensure
POST /api/v1/users/ensure
{
"clerk_user_id": "user_2abc123...",
"email": "user@example.com",
"name": "User Name"
}
# 5. Flask endpoint (idempotent):
def ensure_user():
# Check if user exists in our DB
existing_user = User.query.filter_by(clerk_user_id=clerk_user_id).first()
if existing_user:
# Return existing org
return {"organization_id": existing_user.org_id, "created": False}
# NEW USER: Check Clerk for existing orgs FIRST (prevents duplicates!)
existing_orgs = clerk_service.get_user_organizations(clerk_user_id)
if existing_orgs:
# User already has org in Clerk - reuse it
clerk_org = existing_orgs[0]
else:
# No org exists - create one
clerk_org = clerk_service.create_organization(
name=f"{username}'s Workspace",
created_by=clerk_user_id
)
# Create user in our DB
user = User(clerk_user_id=clerk_user_id, email=email, ...)
# Create membership
membership = Membership(
user_id=user.id,
tenant_id=clerk_org['id'], # Clerk org ID
role='owner'
)
return {"organization_id": clerk_org['id'], "created": True}
Why idempotent?
- Checks for existing org BEFORE creating new one
- Handles race conditions with IntegrityError catch
- Calling multiple times has same effect as calling once
Multi-Tenancy
All data is scoped by tenant_id (Clerk organization ID):
-- Keys are tenant-scoped
SELECT * FROM keys WHERE tenant_id = :org_id
-- Bundles are tenant-scoped
SELECT * FROM bundles WHERE tenant_id = :org_id
-- Membership verification (maps Clerk user to tenant)
SELECT m.role FROM memberships m
JOIN users u ON m.user_id = u.id
WHERE m.tenant_id = :org_id
AND u.clerk_user_id = :clerk_user_id
Key points:
- Every protected route validates org membership
- Users can only access data in their organization
- Clerk org ID stored in
tenants.idandmemberships.tenant_id - Internal user UUID stored in
users.idandmemberships.user_id
Decorator Reference
@require_org_membership
Primary authentication decorator for all routes.
What it does:
- Extracts JWT from
Authorization: Bearer <token>header - Fetches Clerk JWKS (JSON Web Key Set)
- Verifies JWT signature
- Extracts claims (user_id, org_id, role)
- Verifies user is member of org in database
- Sets Flask g context variables
Usage:
from utils.org_auth import require_org_membership
@bp.route('/api/v1/bundles')
@require_org_membership
def list_bundles():
org_id = g.org_id # Clerk org ID
role = g.org_role # 'admin' or 'member'
# Query bundles for this org
bundles = Bundle.query.filter_by(tenant_id=org_id).all()
return jsonify([b.to_dict() for b in bundles])
Sets Flask g variables:
g.user_id- Clerk user ID (e.g.,user_2abc123...)g.org_id- Clerk org ID (e.g.,org_2xyz789...)g.org_role- User's role in org (adminormember)
Database Schema
Users Table
Maps Clerk users to internal UUIDs:
CREATE TABLE users (
id TEXT PRIMARY KEY, -- Internal UUID
clerk_user_id TEXT UNIQUE, -- Clerk user ID (user_2...)
username TEXT UNIQUE,
email TEXT UNIQUE,
auth_provider TEXT DEFAULT 'clerk',
password_hash TEXT, -- NULL for Clerk users
created_at TIMESTAMP,
updated_at TIMESTAMP
);
Tenants Table
Stores Clerk organization IDs:
CREATE TABLE tenants (
id TEXT PRIMARY KEY, -- Clerk org ID (org_2...)
owner_id TEXT, -- Internal user UUID
name TEXT
);
Memberships Table
Links users to organizations:
CREATE TABLE memberships (
id TEXT PRIMARY KEY,
user_id TEXT, -- Internal UUID (→ users.id)
tenant_id TEXT, -- Clerk org ID (→ tenants.id)
role TEXT CHECK(role IN ('owner', 'member')),
created_at TIMESTAMP,
UNIQUE(user_id, tenant_id)
);
Why the JOIN?
- Clerk JWT contains
clerk_user_id(e.g.,user_2abc123) - Memberships table uses internal
user_id(UUID) - Must JOIN to map between them:
SELECT m.role FROM memberships m
JOIN users u ON m.user_id = u.id
WHERE u.clerk_user_id = :clerk_user_id
Security Considerations
Token Validation
Every request validates:
- Signature - JWT signed by Clerk (verified with JWKS)
- Expiration - Token not expired
- Issuer - Token issued by correct Clerk instance
- Audience - Token intended for this application
- Membership - User is member of organization in database
Tenant Isolation
All queries enforce tenant isolation:
# ✅ Correct (tenant-scoped)
bundles = Bundle.query.filter_by(tenant_id=g.org_id).all()
# ❌ Wrong (cross-tenant leak!)
bundles = Bundle.query.all()
Defense in Depth
Even with valid JWT, verify database membership:
# JWT says user is in org_123, but did admin remove them?
# Check database to allow instant revocation
membership = verify_org_membership(g.org_id, g.user_id)
if not membership:
return jsonify({"error": "No access"}), 403
Public Routes
Some routes don't require authentication:
Next.js Middleware (web/middleware.ts):
const isPublicRoute = createRouteMatcher([
'/',
'/sign-in(.*)',
'/sign-up(.*)',
])
const isPublicApiRoute = createRouteMatcher([
'/api/v1/requests(.*)', // CLI approval flow
])
Flask routes without @require_org_membership:
POST /api/v1/requests- Create approval request (CLI)GET /api/v1/requests/{id}/wait- Poll for approval (CLI)GET /api/v1/requests/{id}/context- Get request info (public)
Troubleshooting
"Unauthorized" errors
Check:
- User is logged in (Clerk session exists)
- JWT token is being forwarded (check Authorization header)
- Clerk JWKS is accessible (check logs for "AUTH: JWKS has N keys")
- User has membership in database (check memberships table)
"No access to this tenant" errors
Check:
- User is member of organization in Clerk
- Membership exists in local database (
membershipstable) tenant_idmatches Clerk org ID- User hasn't been removed from org
Duplicate organizations
If users get multiple orgs on signup:
Cause: Race condition in /api/v1/users/ensure
Fix: Already implemented - endpoint checks for existing org before creating
Verify:
-- Should see only 1 org per user
SELECT u.email, COUNT(DISTINCT m.tenant_id) as org_count
FROM users u
JOIN memberships m ON u.id = m.user_id
GROUP BY u.email
HAVING org_count > 1;