Skip to main content

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.id and memberships.tenant_id
  • Internal user UUID stored in users.id and memberships.user_id

Decorator Reference

@require_org_membership

Primary authentication decorator for all routes.

What it does:

  1. Extracts JWT from Authorization: Bearer <token> header
  2. Fetches Clerk JWKS (JSON Web Key Set)
  3. Verifies JWT signature
  4. Extracts claims (user_id, org_id, role)
  5. Verifies user is member of org in database
  6. 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 (admin or member)

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:

  1. Signature - JWT signed by Clerk (verified with JWKS)
  2. Expiration - Token not expired
  3. Issuer - Token issued by correct Clerk instance
  4. Audience - Token intended for this application
  5. 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:

  1. User is logged in (Clerk session exists)
  2. JWT token is being forwarded (check Authorization header)
  3. Clerk JWKS is accessible (check logs for "AUTH: JWKS has N keys")
  4. User has membership in database (check memberships table)

"No access to this tenant" errors

Check:

  1. User is member of organization in Clerk
  2. Membership exists in local database (memberships table)
  3. tenant_id matches Clerk org ID
  4. 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;