Skip to main content

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:

  1. User visits /sign-up
  2. Enters email + password
  3. Clerk sends verification email
  4. User clicks verification link
  5. Redirected to dashboard
  6. BFF calls POST /api/v1/users/ensure to create user + tenant

Sign In

URL: https://env.cat/sign-in

Flow:

  1. User visits /sign-in
  2. Enters credentials
  3. Clerk validates credentials
  4. Session cookie set (httpOnly, secure)
  5. 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:

  1. User clicks "Sign Out"
  2. Clerk session destroyed
  3. Cookie deleted
  4. 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-Signature headers
  • Recalculates signature with BFF_SHARED_SECRET
  • Compares signatures (constant-time comparison)
  • Sets g.user_id and g.tenant_id for endpoint use
  • Returns 401 Unauthorized if 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 Membership table for user's role
  • Returns 403 Forbidden if 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 Membership table for user's role
  • Returns 403 Forbidden if 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):

  1. Sign in at http://localhost:8080/sign-in
  2. Get session cookie from browser
  3. 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