Authentication
title: Authentication API audience: Developer difficulty: Intermediate estimated_read_time: 5 min prerequisites:
- Basic auth concepts
- Understanding of BFF pattern related_pages:
- ../getting-started/authentication.md
- ../architecture/authentication.md
env.cat uses Clerk for user authentication and a BFF (Backend for Frontend) layer for API security.
Overview
Authentication Flow:
User Browser
↓
Clerk Sign-In (https://env.cat/sign-in)
↓
Clerk Session Cookie (httpOnly, secure)
↓
Next.js Middleware (validates Clerk session)
↓
BFF API Routes (/api/v1/*)
↓
HMAC-signed headers (X-User-ID, X-Tenant-ID, X-Signature)
↓
Flask API (http://api:5000)
↓
@verify_bff_signature decorator
↓
@require_role / @require_owner / @require_member decorators
↓
Endpoint Logic
Clerk Authentication
Sign Up
URL: https://env.cat/sign-up
Methods:
- Email + Password - Default (always enabled)
- OAuth - Google, GitHub (if enabled in Clerk Dashboard)
Flow:
- User visits
/sign-up - Enters email + password
- Clerk sends verification email
- User clicks verification link
- Redirected to dashboard
- BFF calls
POST /api/v1/users/ensureto create user + tenant
Sign In
URL: https://env.cat/sign-in
Flow:
- User visits
/sign-in - Enters credentials
- Clerk validates credentials
- Session cookie set (httpOnly, secure)
- Redirected to dashboard
Session Management
Session Cookie:
- Name:
__session(Clerk default) - Type: httpOnly, secure, SameSite=Lax
- Duration: 7 days (active), 30 minutes (idle)
Validation:
Next.js middleware validates Clerk session on every request:
// web/middleware.ts
import { authMiddleware } from "@clerk/nextjs";
export default authMiddleware({
publicRoutes: ["/", "/sign-in", "/sign-up", "/api/webhooks/clerk"]
});
Sign Out
URL: https://env.cat/sign-out
Flow:
- User clicks "Sign Out"
- Clerk session destroyed
- Cookie deleted
- Redirected to homepage
BFF Layer (Next.js API Routes)
All client requests go through Next.js BFF at https://env.cat/api/v1/*.
Why BFF?
- Security: Flask API never exposed to internet (internal port 5000)
- Session handling: Next.js handles Clerk session validation
- Signature: BFF signs requests to Flask with HMAC
BFF → Flask Authentication
Headers:
X-User-ID: user_2abc123xyz
X-Tenant-ID: 550e8400-e29b-41d4-a716-446655440000
X-Signature: sha256=abc123...
Signature Calculation:
# BFF (Next.js)
import { createHmac } from 'crypto';
const payload = `${userId}:${tenantId}`;
const signature = createHmac('sha256', BFF_SHARED_SECRET)
.update(payload)
.digest('hex');
Flask Verification:
# Flask API
@verify_bff_signature
def my_endpoint():
user_id = g.user_id # Set by decorator
tenant_id = g.tenant_id # Set by decorator
# ...
Flask API Authorization
Decorators
@verify_bff_signature
Purpose: Verify request came from BFF (not public internet)
Usage:
@bp.route('/api/v1/bundles', methods=['GET'])
@verify_bff_signature
def list_bundles():
user_id = g.user_id # Available after verification
tenant_id = g.tenant_id
# ...
Behavior:
- Extracts
X-User-ID,X-Tenant-ID,X-Signatureheaders - Recalculates signature with
BFF_SHARED_SECRET - Compares signatures (constant-time comparison)
- Sets
g.user_idandg.tenant_idfor endpoint use - Returns
401 Unauthorizedif signature invalid
@require_owner
Purpose: Require user to be tenant owner
Usage:
@bp.route('/api/v1/bundles', methods=['POST'])
@verify_bff_signature
@require_owner
def create_bundle():
# Only owners can create bundles
# ...
Behavior:
- Queries
Membershiptable for user's role - Returns
403 Forbiddenif not owner - Continues if owner
@require_member
Purpose: Require user to be tenant member (owner or member)
Usage:
@bp.route('/api/v1/bundles', methods=['GET'])
@verify_bff_signature
@require_member
def list_bundles():
# Members can list bundles
# ...
Behavior:
- Queries
Membershiptable for user's role - Returns
403 Forbiddenif not member - Continues if owner or member
@require_role(role)
Purpose: Require specific role (flexible)
Usage:
@bp.route('/api/v1/keys', methods=['POST'])
@verify_bff_signature
@require_role('owner')
def create_key():
# Only owners can create keys
# ...
Roles:
owner- Full access (create, update, delete)member- Read access + limited write
Public Endpoints
Some endpoints are public (no authentication required):
POST /api/v1/requests
Purpose: Create approval request (CLI)
Authentication: None (public)
Why public: CLI doesn't have credentials, only ephemeral keypair
GET /api/v1/requests/:id/wait
Purpose: Poll for approval (CLI)
Authentication: None (public)
Why public: CLI polling, no sensitive data exposed
GET /api/v1/requests/:id/context
Purpose: Get request context (approval page)
Authentication: None (public)
Why public: Needed to show approval UI before authentication
POST /api/v1/users/ensure
Purpose: Just-in-time user provisioning
Authentication: None (called by BFF)
Security: Should add BFF signature verification (TODO)
Authentication Headers
Required Headers (BFF → Flask)
All authenticated endpoints require:
X-User-ID: user_2abc123xyz
X-Tenant-ID: 550e8400-e29b-41d4-a716-446655440000
X-Signature: sha256=abc123...
Optional Headers
Content-Type: application/json
Accept: application/json
Error Responses
401 Unauthorized
Cause: Invalid or missing BFF signature
Response:
{
"error": "Unauthorized"
}
Solution: Ensure BFF is properly signing requests
403 Forbidden
Cause: Insufficient permissions (not owner/member)
Response:
{
"error": "Forbidden: owner role required"
}
Solution: User needs higher role or different tenant
404 Not Found (Authentication)
Cause: User or tenant not found in database
Response:
{
"error": "User not found"
}
Solution: Call POST /api/v1/users/ensure to create user
Multi-Tenancy
Personal Tenant
Created automatically on first sign-in:
{
"tenant_id": "550e8400-e29b-41d4-a716-446655440000",
"name": "username's workspace",
"owner_id": "user_2abc123xyz",
"role": "owner"
}
Tenant Isolation
All data scoped to tenant:
# Bundles scoped to tenant
bundles = session.query(Bundle).filter_by(tenant_id=tenant_id).all()
# Keys scoped to tenant
keys = session.query(Key).filter_by(tenant_id=tenant_id).all()
Switching Tenants
Future feature: Users can be members of multiple tenants
POST /api/v1/tenants/switch
Content-Type: application/json
{
"tenant_id": "another-tenant-uuid"
}
Security Considerations
BFF Secret
Environment Variable: BFF_SHARED_SECRET
Requirements:
- Minimum 32 bytes (256 bits)
- Random, cryptographically secure
- Rotate regularly (every 90 days)
Generate:
openssl rand -base64 32
Clerk Keys
Environment Variables:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY- Public (frontend)CLERK_SECRET_KEY- Secret (backend only)
Security:
- Never commit to git
- Store in environment variables
- Rotate if compromised
Session Security
Clerk Session:
- httpOnly cookie (XSS protection)
- Secure flag (HTTPS only)
- SameSite=Lax (CSRF protection)
- 7-day expiration (rolling)
Example: Authenticated Request
Browser → BFF
GET /api/v1/bundles HTTP/1.1
Host: env.cat
Cookie: __session=clerk_session_token_here
BFF → Flask
GET /api/v1/bundles HTTP/1.1
Host: api:5000
X-User-ID: user_2abc123xyz
X-Tenant-ID: 550e8400-e29b-41d4-a716-446655440000
X-Signature: sha256=abc123def456...
Flask Response
[
{
"id": "bundle-uuid",
"name": "dev/api",
"description": "Development API secrets",
"created_at": "2025-10-22T10:00:00Z"
}
]
BFF → Browser
[
{
"id": "bundle-uuid",
"name": "dev/api",
"description": "Development API secrets",
"created_at": "2025-10-22T10:00:00Z"
}
]
Testing Authentication
Local Development
Start services:
docker compose up -d
Test public endpoint (no auth):
curl -X POST http://localhost:8080/api/v1/requests \
-H "Content-Type: application/json" \
-d '{"client_pubkey":"base64pubkey"}'
Test authenticated endpoint (via BFF):
- Sign in at
http://localhost:8080/sign-in - Get session cookie from browser
- Make request with cookie:
curl http://localhost:8080/api/v1/bundles \
-H "Cookie: __session=your_session_cookie_here"
Production
Test public endpoint:
curl -X POST https://env.cat/api/v1/requests \
-H "Content-Type: application/json" \
-d '{"client_pubkey":"base64pubkey"}'
Test authenticated endpoint:
Sign in at https://env.cat/sign-in and use browser session.
See Also
- Bundles API - Bundle endpoints (authenticated)
- Keys API - Key endpoints (authenticated)
- Requests API - Approval endpoints (public + authenticated)
- Users API - User provisioning (public)
- Errors - Error codes and handling