API Protection Architecture Documentation¶
Version: 2.0 Last Updated: January 2026 Status: Implemented - Service Auth + Vercel WAF Rate Limiting
Table of Contents¶
- Executive Summary
- Architecture Overview
- Role & Permission Design
- Rate Limiting Architecture
- Paywall System Design
- Middleware Architecture
- Database Schema Design
- Stripe Integration Design
- Module Structure
- Security Considerations
- API Response Patterns
- Configuration Reference
- Design Decisions
1. Executive Summary¶
Purpose¶
This document defines the architecture for implementing comprehensive API protection across the micro-learning platform. The protection system spans two API layers and provides:
- Tiered access control based on user subscription status
- Rate limiting with tier-based quotas
- Paywall protection for premium content
- Audit logging for security monitoring
Scope¶
| Component | Technology | Protection Responsibilities |
|---|---|---|
| Experience APIs | Next.js on Vercel | Primary enforcement, BFF orchestration |
| Domain APIs | Supabase Edge Functions (Deno/Hono) | Defense in depth, RLS integration |
| Database | Supabase PostgreSQL | RLS policies, subscription storage |
| Payments | Stripe | Subscription management |
Key Design Principles¶
- Defense in Depth: Enforce at both Experience and Domain layers
- Fail Secure: Default to deny; require explicit permissions
- Graceful Degradation: Anonymous users get preview access, not blocked
- Performance First: Edge-based rate limiting, JWT-based roles (no DB lookup)
- Pluggable Design: Protection module works independently of business logic
- Service Authentication: Domain APIs only accessible via Experience layer (X-Service-Auth)
2. Architecture Overview¶
2.1 System Context Diagram¶
┌─────────────────────────────────────────────────────────────────────────────────┐
│ CLIENT APPLICATIONS │
│ Web Browser │ iOS App (Future) │ Android App (Future) │
└─────────────────────────────────────────────────────────────────────────────────┘
│
│ HTTPS + JWT
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ EXPERIENCE LAYER (Vercel BFF) │
│ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ Vercel WAF / Edge Config │ │
│ │ │ │
│ │ • IP-based rate limiting at edge (configured in Vercel Dashboard) │ │
│ │ • DDoS protection │ │
│ │ • Bot detection │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ Next.js Edge Middleware │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │ │
│ │ │ Auth │──▶│ Tier │──▶│ Permission │──▶│ Paywall │ │ │
│ │ │ Extractor │ │ Resolver │ │ Checker │ │ Checker │ │ │
│ │ │ (JWT Parse) │ │ (Free/Pro/ │ │ (RBAC) │ │ (Feature │ │ │
│ │ │ │ │ Premium) │ │ │ │ Gates) │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ └─────────────┘ │ │
│ │ │ │ │ │
│ │ │◀────────────────── Headers ────────────────────────────▶│ │ │
│ │ │ X-Service-Auth + User Context Propagated │ │ │
│ └─────────┼─────────────────────────────────────────────────────────┼────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────────┐│
│ │ API Route Handlers ││
│ │ ││
│ │ /api/discovery/* │ /api/domains/* │ /api/me/* │ /api/search/* ││
│ └─────────────────────────────────────────────────────────────────────────────┘│
│ │ │
│ Domain API Client (HTTP) │
│ │ │
└────────────────────────────────────────┼────────────────────────────────────────┘
│
│ HTTPS + JWT + X-Service-Auth
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ DOMAIN LAYER (Supabase Edge) │
│ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ Hono Middleware Stack │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │ │
│ │ │ Service │──▶│ Auth │──▶│ User │──▶│ Permission │ │ │
│ │ │ Auth │ │ Handler │ │ Context │ │ Guard │ │ │
│ │ │ (X-Service- │ │ (JWT Verify) │ │ Extractor │ │ (RBAC) │ │ │
│ │ │ Auth) │ │ │ │ │ │ │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ └─────────────┘ │ │
│ │ │ │ │
│ │ │ Rejects requests without valid X-Service-Auth header │ │
│ │ │ (Direct access blocked - only Experience layer allowed) │ │
│ └─────────┼──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ Route Handlers │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────────────┐│
│ │ Supabase PostgreSQL + RLS ││
│ │ ││
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌──────────┐ ││
│ │ │ domains │ │ trails │ │ sparks │ │ journeys │ │subscrip- │ ││
│ │ │ (RLS) │ │ (RLS) │ │ (RLS) │ │ (RLS) │ │ tions │ ││
│ │ └───────────┘ └───────────┘ └───────────┘ └───────────┘ └──────────┘ ││
│ └─────────────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────────────┘
2.2 Protection Flow Sequence¶
Client Experience Layer Domain Layer Database
│ │ │ │
│──── Request + JWT ─────▶│ │ │
│ │ │ │
│ ┌─────┴─────┐ │ │
│ │ Vercel WAF│ │ │
│ │Rate Limit │ │ │
│ └─────┬─────┘ │ │
│ │ │ │
│ ┌─────┴─────┐ │ │
│ │ Decode │ │ │
│ │ JWT │ │ │
│ └─────┬─────┘ │ │
│ │ │ │
│ ┌─────┴─────┐ │ │
│ │ Resolve │ │ │
│ │ Role │ │ │
│ └─────┬─────┘ │ │
│ │ │ │
│ ┌─────┴─────┐ │ │
│ │ Check │ │ │
│ │Permission │ │ │
│ └─────┬─────┘ │ │
│ │ │ │
│ │── Request + X-Service-Auth + JWT ────▶│ │
│ │ │ │
│ │ ┌─────┴─────┐ │
│ │ │ Verify │ │
│ │ │ X-Service │ │
│ │ │ Auth │ │
│ │ └─────┬─────┘ │
│ │ │ │
│ │ ┌─────┴─────┐ │
│ │ │ Verify │ │
│ │ │ JWT │ │
│ │ └─────┬─────┘ │
│ │ │ │
│ │ │── Query + RLS ─────▶│
│ │ │ │
│ │ │◀──── Results ───────│
│ │ │ │
│ │◀─────── Response ───────│ │
│ │ │ │
│ ┌─────┴─────┐ │ │
│ │ Apply │ │ │
│ │ Paywall │ │ │
│ └─────┬─────┘ │ │
│ │ │ │
│◀─── Response + Headers ─│ │ │
│ │ │ │
2.3 Layer Responsibilities Matrix¶
| Responsibility | Experience Layer | Domain Layer | Database |
|---|---|---|---|
| Rate Limiting | Vercel WAF/Edge | - (removed) | - |
| Service Auth | Adds X-Service-Auth header | Verifies X-Service-Auth | - |
| Authentication | JWT parsing | JWT validation | - |
| Role Resolution | From JWT claims | Verify claims | Store subscription |
| Permission Check | Route-level | Handler-level | - |
| Paywall Enforcement | Content transformation | Flag content | access_tier column |
| Audit Logging | Structured stdout | Structured stdout | - |
| RLS Enforcement | - | - | Row-level policies |
3. Role & Permission Design¶
3.1 User Role Hierarchy¶
┌──────────┐
│ Admin │ ← Full platform access
└────┬─────┘
│
┌────┴─────┐
│ Service │ ← API-to-API access
└────┬─────┘
│
┌────┴─────┐
│ Premium │ ← Highest paid tier
└────┬─────┘
│
┌────┴─────┐
│ Pro │ ← Standard paid tier
└────┬─────┘
│
┌────┴─────┐
│ Free │ ← Authenticated, no subscription
└────┬─────┘
│
┌────┴─────┐
│Anonymous │ ← Unauthenticated
└──────────┘
3.2 Role Definitions¶
enum UserRole {
ANONYMOUS = 'anonymous', // No authentication
FREE = 'free', // Authenticated, no subscription
PRO = 'pro', // Monthly/yearly subscription
PREMIUM = 'premium', // Higher tier subscription
ADMIN = 'admin', // Platform administrators
SERVICE = 'service', // Service accounts (internal)
}
3.3 Permission Definitions¶
enum Permission {
// Content Access
READ_PUBLIC_CONTENT = 'read:public_content',
READ_PREVIEW_CONTENT = 'read:preview_content',
READ_FULL_CONTENT = 'read:full_content',
READ_PREMIUM_CONTENT = 'read:premium_content',
// Learning Features
TRACK_PROGRESS = 'track:progress',
CREATE_JOURNEY = 'create:journey',
ACCESS_SPACED_REPETITION = 'access:spaced_repetition',
ACCESS_ADVANCED_ANALYTICS = 'access:advanced_analytics',
// Search Features
BASIC_SEARCH = 'search:basic',
ADVANCED_SEARCH = 'search:advanced',
UNLIMITED_SEARCH = 'search:unlimited',
// Admin Features
MANAGE_CONTENT = 'manage:content',
MANAGE_USERS = 'manage:users',
VIEW_ANALYTICS = 'view:analytics',
BYPASS_RATE_LIMITS = 'bypass:rate_limits',
}
3.4 Role-Permission Matrix¶
| Permission | Anonymous | Free | Pro | Premium | Admin | Service |
|---|---|---|---|---|---|---|
read:public_content |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
read:preview_content |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
read:full_content |
- | ✓ | ✓ | ✓ | ✓ | ✓ |
read:premium_content |
- | - | ✓ | ✓ | ✓ | ✓ |
search:basic |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
search:advanced |
- | - | ✓ | ✓ | ✓ | ✓ |
search:unlimited |
- | - | - | ✓ | ✓ | ✓ |
track:progress |
- | ✓ | ✓ | ✓ | ✓ | - |
create:journey |
- | ✓ | ✓ | ✓ | ✓ | - |
access:spaced_repetition |
- | - | ✓ | ✓ | ✓ | - |
access:advanced_analytics |
- | - | - | ✓ | ✓ | - |
manage:content |
- | - | - | - | ✓ | - |
manage:users |
- | - | - | - | ✓ | - |
bypass:rate_limits |
- | - | - | - | ✓ | ✓ |
3.5 UserContext Type¶
interface UserContext {
id: string | null; // User UUID or null for anonymous
role: UserRole; // Resolved role
permissions: Permission[]; // Granted permissions
subscriptionActive: boolean; // Whether subscription is active
subscriptionPlan: string | null; // 'pro', 'premium', or null
}
3.6 JWT Claims Structure¶
Roles are stored in JWT custom claims via Supabase Auth Hook:
{
"aud": "authenticated",
"exp": 1736496000,
"sub": "user-uuid-here",
"email": "user@example.com",
"role": "authenticated",
"user_role": "pro",
"subscription_active": true,
"subscription_plan": "pro",
"app_metadata": {
"provider": "email"
},
"user_metadata": {
"display_name": "John Doe"
}
}
4. Rate Limiting Architecture¶
Architecture Change (v2.0): In-memory rate limiting has been removed from both Experience and Domain layers. Rate limiting is now handled by Vercel WAF/Edge at the infrastructure level. This change was made because serverless functions (Supabase Edge Functions) are stateless and cannot reliably maintain in-memory rate limit state across cold starts and multiple instances.
4.1 Current Implementation¶
Rate Limiting Location: Vercel WAF/Edge (configured in Vercel Dashboard)
Why In-Memory Was Removed: - Supabase Edge Functions are serverless and stateless - Cold starts reset in-memory state - Multiple instances don't share memory - Rate limits were unreliable and easily bypassed
Current Flow:
4.2 Algorithm Reference: Sliding Window Counter (Historical)¶
The following describes the sliding window counter algorithm that was previously used. This is kept for reference in case persistent rate limiting (e.g., Upstash Redis) is added in the future.
Time ─────────────────────────────────────────────────────▶
Window 1 (Fixed) Window 2 (Fixed)
├────────────────────────┼────────────────────────┤
│ Requests: 30 │ Requests: 25 │
│ │ │
│ ┌──────────────┼──────────┐ │
│ │ Sliding Window (60s) │ │
│ │ Weighted: 0.3×30 + 25 │ │
│ │ = 34 requests │ │
│ └──────────────┼──────────┘ │
Formula:
4.2 Rate Limit Tiers¶
| Category | Anonymous | Free | Pro | Premium | Admin |
|---|---|---|---|---|---|
| Default | 30/min | 100/min | 300/min | 600/min | ∞ |
| Search | 10/min | 30/min | 100/min | 200/min | ∞ |
| Autocomplete | 30/min | 60/min | 120/min | 300/min | ∞ |
| Content | 20/min | 60/min | 200/min | 500/min | ∞ |
| Events | - | 60/min | 120/min | 300/min | ∞ |
4.3 Rate Limiter Configuration¶
interface RateLimitConfig {
windowMs: number; // Window size in milliseconds (default: 60000)
maxRequests: number; // Max requests per window
keyPrefix: string; // Cache key prefix for isolation
}
const RATE_LIMITS: Record<UserRole, Record<string, RateLimitConfig>> = {
[UserRole.ANONYMOUS]: {
'default': { windowMs: 60_000, maxRequests: 30, keyPrefix: 'rl:anon' },
'search': { windowMs: 60_000, maxRequests: 10, keyPrefix: 'rl:anon:search' },
'autocomplete': { windowMs: 60_000, maxRequests: 30, keyPrefix: 'rl:anon:ac' },
'content': { windowMs: 60_000, maxRequests: 20, keyPrefix: 'rl:anon:content' },
},
// ... other roles
};
4.4 Rate Limiter Implementation¶
class MemoryRateLimiter {
private store: Map<string, { count: number; windowStart: number }>;
private maxEntries: number = 10000;
check(identifier: string, config: RateLimitConfig): RateLimitResult {
const key = `${config.keyPrefix}:${identifier}`;
const now = Date.now();
let entry = this.store.get(key);
// Reset if window expired
if (!entry || now - entry.windowStart >= config.windowMs) {
entry = { count: 0, windowStart: now };
}
entry.count++;
this.store.set(key, entry);
// Periodic cleanup of old entries
if (this.store.size > this.maxEntries) {
this.cleanup(now - config.windowMs * 2);
}
return {
allowed: entry.count <= config.maxRequests,
remaining: Math.max(0, config.maxRequests - entry.count),
resetAt: Math.ceil((entry.windowStart + config.windowMs) / 1000),
retryAfter: entry.count > config.maxRequests
? Math.ceil((entry.windowStart + config.windowMs - now) / 1000)
: undefined,
};
}
}
4.5 Client Identifier Resolution¶
function getClientIdentifier(request: Request, userContext: UserContext): string {
// Priority order:
// 1. Authenticated user ID (most reliable)
if (userContext.id) {
return `user:${userContext.id}`;
}
// 2. Client IP (for anonymous)
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| request.headers.get('cf-connecting-ip')
|| request.headers.get('x-real-ip')
|| 'unknown';
return `ip:${ip}`;
}
4.6 Anonymous Rate Limiting (No API Token Required)¶
For public Browse APIs that allow anonymous access (verify_jwt = false), rate limiting is implemented without requiring any API tokens or JWT authentication. This section explains how.
4.6.1 How It Works¶
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Anonymous Rate Limiting Flow │
└─────────────────────────────────────────────────────────────────────────────────┘
Client CDN/Proxy Edge Function
│ │ │
│──── HTTP Request ────────▶│ │
│ │ │
│ Add forwarding headers: │
│ • X-Forwarded-For: client IP │
│ • CF-Connecting-IP (Cloudflare) │
│ • X-Real-IP (nginx) │
│ │ │
│ │── Request + Headers ─────▶│
│ │ │
│ │ ┌──────┴──────┐
│ │ │ Extract IP │
│ │ │ from headers│
│ │ └──────┬──────┘
│ │ │
│ │ ┌──────┴──────┐
│ │ │ Rate limit │
│ │ │ check by IP │
│ │ └──────┬──────┘
│ │ │
│ │◀── Response + Headers ────│
│ │ X-RateLimit-* │
│ │ │
│◀── Response ─────────────│ │
4.6.2 IP Address Extraction¶
The edge function extracts the client IP from HTTP headers set by CDN/proxy infrastructure:
function extractClientIP(getHeader: (name: string) => string | undefined): string {
// Priority order - most reliable to least reliable
const ipSources = [
// 1. Cloudflare's connecting IP (most reliable when using Cloudflare)
getHeader('cf-connecting-ip'),
// 2. Standard forwarding header (first IP is original client)
getHeader('x-forwarded-for')?.split(',')[0]?.trim(),
// 3. nginx real IP header
getHeader('x-real-ip'),
// 4. Supabase-specific headers
getHeader('x-client-ip'),
];
// Return first non-empty value, or 'unknown' as fallback
for (const ip of ipSources) {
if (ip && ip !== 'unknown') {
return ip;
}
}
return 'unknown';
}
4.6.3 Serverless Rate Limiting Challenge¶
Important: Supabase Edge Functions are serverless and stateless. They can: - Cold start on each request - Scale horizontally across multiple isolated instances - Not share memory between invocations - Be terminated at any time
In-memory rate limiting alone is NOT sufficient for serverless functions.
4.6.4 Multi-Layer Protection Strategy¶
Given the stateless nature of edge functions, protection is implemented through multiple complementary layers:
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Multi-Layer Protection for Serverless │
└─────────────────────────────────────────────────────────────────────────────────┘
Layer 1: CDN/Edge (Cloudflare/Supabase Gateway)
┌─────────────────────────────────────────────────────────────────────────────┐
│ • DDoS protection (automatic) │
│ • Geographic rate limiting │
│ • Bot detection │
│ • Request throttling at edge │
│ • WAF rules for suspicious patterns │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
Layer 2: Supabase Gateway (verify_jwt)
┌─────────────────────────────────────────────────────────────────────────────┐
│ • JWT verification for protected functions │
│ • Reject unauthenticated requests to user/snapshots functions │
│ • Pass-through for public functions (browse, search, etc.) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
Layer 3: Edge Function (Application-Level)
┌─────────────────────────────────────────────────────────────────────────────┐
│ • Best-effort in-memory rate limiting (within worker lifetime) │
│ • User context extraction from JWT │
│ • Permission checks │
│ • Audit logging │
│ • Rate limit headers (informational) │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
Layer 4: Database (PostgreSQL RLS)
┌─────────────────────────────────────────────────────────────────────────────┐
│ • Row-level security policies │
│ • Data access control based on user context │
│ • Ultimate defense against unauthorized data access │
└─────────────────────────────────────────────────────────────────────────────┘
4.6.5 Rate Limiting Implementation Options¶
| Option | Pros | Cons | Recommended For |
|---|---|---|---|
| Supabase Edge Gateway | Built-in, no code needed | Limited customization | Basic protection |
| Upstash Redis | Persistent, shared state | External dependency, latency | Production at scale |
| Cloudflare Rate Limiting | Edge-level, very fast | Requires Cloudflare plan | High-traffic APIs |
| In-Memory (Best-Effort) | Zero latency, no dependencies | Resets on cold start | Supplementary only |
| Database Counter | Persistent, accurate | Higher latency, DB load | Critical endpoints |
4.6.6 Recommended Production Architecture¶
For robust rate limiting in serverless environments, use Upstash Redis or similar:
// Production rate limiting with Upstash Redis
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: Deno.env.get("UPSTASH_REDIS_REST_URL")!,
token: Deno.env.get("UPSTASH_REDIS_REST_TOKEN")!,
});
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, "60 s"), // 20 requests per minute
analytics: true,
prefix: "rl:browse",
});
// In middleware
const identifier = clientIP; // or userId for authenticated
const { success, limit, remaining, reset } = await ratelimit.limit(identifier);
if (!success) {
return c.json({ error: { code: "RATE_LIMITED" } }, 429);
}
Why Upstash Redis? - Serverless-native: HTTP-based, no connection pooling issues - Global replication: Low latency worldwide - Persistent: Survives cold starts and scaling - Cost-effective: Pay-per-request pricing
4.6.7 Current Implementation Status¶
| Layer | Status | Notes |
|---|---|---|
| CDN/DDoS Protection | ✅ Active | Supabase provides automatic DDoS protection |
| Vercel WAF Rate Limiting | ✅ Active | Configure in Vercel Dashboard |
| Service Authentication | ✅ Active | X-Service-Auth header required for Domain APIs |
| Gateway JWT Verification | ✅ Active | verify_jwt in config.toml |
| In-Memory Rate Limiting | ❌ Removed | Was unreliable in serverless |
| Upstash/Redis Rate Limiting | 🔲 Optional | Can add for more granular control |
| Database RLS | ✅ Active | Row-level security enforced |
4.6.8 Best-Effort In-Memory Rate Limiter¶
The current in-memory implementation provides supplementary protection within worker lifetime:
// Singleton rate limiter - persists while worker is warm
const rateLimiter = new MemoryRateLimiter();
// Store structure: Map<identifier, { count, windowStart }>
// Example entries (while worker is warm):
// "rl:anon:content:ip:203.0.113.45" → { count: 15, windowStart: 1704067140000 }
Behavior: - Warm Worker: Rate limits enforced within that instance - Cold Start: Rate limit state resets (new Map) - Multiple Instances: Each has independent counters - Result: Limits are "soft" - may allow more requests than configured during scaling
When This Works: - Sustained traffic keeps workers warm - Single-instance deployments - As a second line of defense with external rate limiting
When This Fails: - Bursty traffic with cold starts - Distributed attacks - High-scale multi-instance deployments
4.6.9 Rate Limit Identifier Format¶
For anonymous requests, the identifier combines the key prefix with the client IP:
Format: {keyPrefix}:{identifier_type}:{value}
Examples:
- rl:anon:content:ip:203.0.113.45 (anonymous, content endpoint, specific IP)
- rl:anon:search:ip:198.51.100.23 (anonymous, search endpoint, specific IP)
- rl:free:content:user:abc123-uuid (authenticated free user)
4.6.10 Complete Anonymous Request Flow¶
// Inside protection middleware for anonymous-allowed routes
export function protectionMiddleware(config: ProtectionConfig) {
return async (c: Context, next: Function) => {
// 1. Check for JWT (optional for public routes)
const authHeader = c.req.header('Authorization');
let userContext: UserContext;
if (authHeader?.startsWith('Bearer ')) {
// Authenticated request - extract user from JWT
userContext = resolveUserContextFromHeader(authHeader);
} else {
// Anonymous request - no JWT required
userContext = createUserContext(UserRole.ANONYMOUS);
}
// 2. Extract client identifier (IP for anonymous, user ID for authenticated)
const clientIP = extractClientIP((name) => c.req.header(name));
const identifier = createRateLimitIdentifier(userContext.id, clientIP);
// For anonymous: "ip:203.0.113.45"
// For authenticated: "user:abc123-uuid"
// 3. Get rate limit config based on role and endpoint category
const rateLimitConfig = getRateLimitConfig(
userContext.role, // UserRole.ANONYMOUS
config.rateLimitCategory // e.g., "content"
);
// Returns: { windowMs: 60000, maxRequests: 20, keyPrefix: "rl:anon:content" }
// 4. Check rate limit (in-memory)
const result = rateLimiter.check(identifier, rateLimitConfig);
// result: { allowed: true, remaining: 19, resetAt: 1704067200 }
// 5. Set rate limit headers on response
c.header('X-RateLimit-Limit', String(rateLimitConfig.maxRequests));
c.header('X-RateLimit-Remaining', String(result.remaining));
c.header('X-RateLimit-Reset', String(result.resetAt));
// 6. Block if rate limited
if (!result.allowed) {
c.header('Retry-After', String(result.retryAfter));
return c.json({
error: {
code: 'RATE_LIMITED',
message: 'Too many requests',
retryAfter: result.retryAfter
}
}, 429);
}
// 7. Continue to route handler
c.set('userContext', userContext);
await next();
};
}
4.6.11 Why No API Tokens Are Needed¶
| Concern | How It's Addressed |
|---|---|
| Identification | Client IP address from proxy headers uniquely identifies the source |
| Abuse Prevention | Rate limits per IP prevent individual sources from overwhelming the API |
| No Signup Friction | Users can immediately access public content without registration |
| SEO Friendly | Search engine crawlers can index content without authentication |
| Simple Integration | Third-party consumers can use simple HTTP requests |
4.6.12 Limitations and Mitigations¶
| Limitation | Impact | Mitigation |
|---|---|---|
| Shared IPs (NAT/VPN) | Multiple users behind same IP share limit | Higher anonymous limits (20-30/min) accommodate shared IPs |
| IP Spoofing | Attacker could forge X-Forwarded-For | CDN overwrites with actual client IP; trust CDN headers only |
| Distributed Attacks | Attackers using many IPs | WAF rules + DDoS protection at CDN layer |
| Serverless Cold Starts | In-memory state lost | Use Upstash Redis for persistent rate limiting |
| Multiple Worker Instances | Each has independent counters | External rate limiter (Redis) for shared state |
| Worker Restarts/Scaling | Rate limit state lost | Redis-based rate limiting survives restarts |
4.6.13 Upgrading to Authenticated Access¶
Anonymous users hitting rate limits can authenticate to get higher limits:
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Rate Limit Upgrade Path │
└─────────────────────────────────────────────────────────────────────────────────┘
Anonymous (IP-based) Authenticated (User ID-based)
──────────────────── ─────────────────────────────
Content: 20 req/min ───▶ Free: 60 req/min
Search: 10 req/min ───▶ Free: 30 req/min
Default: 30 req/min ───▶ Free: 100 req/min
───▶ Pro: 200-300 req/min
───▶ Premium: 500-600 req/min
Benefits of Authentication:
- Higher Limits: 3-25x higher rate limits
- User-Based Tracking: Limits follow user, not IP (works across devices)
- Personalization: Track progress, save bookmarks
- Premium Content: Access gated content based on subscription
4.7 Response Headers¶
All responses include rate limit headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1736496060
Retry-After: 45 (only on 429 responses)
5. Paywall System Design¶
5.1 Content Tier Model¶
┌────────────────────────────────────────────────────────────────────────┐
│ Content Tiers │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ FREE │ │ PRO │ │ PREMIUM │ │
│ │ │ │ │ │ │ │
│ │ • Basic domains │ │ • Pro domains │ │ • Premium │ │
│ │ • Intro trails │ │ • Advanced │ │ exclusive │ │
│ │ • Public sparks │ │ trails │ │ • Early access │ │
│ │ │ │ • All sparks │ │ • All content │ │
│ │ │ │ │ │ │ │
│ │ Accessible by: │ │ Accessible by: │ │ Accessible by: │ │
│ │ Free, Pro, │ │ Pro, Premium, │ │ Premium, Admin │ │
│ │ Premium, Admin │ │ Admin │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────┘
5.2 Access Decision Flow¶
┌─────────────┐
│ Content │
│ Requested │
└──────┬──────┘
│
┌──────▼──────┐
│ Get │
│ access_tier │
│ from content│
└──────┬──────┘
│
┌──────▼──────┐
│ Get │
│ user role │
└──────┬──────┘
│
┌──────────────┼──────────────┐
│ │ │
┌──────▼──────┐┌─────▼─────┐┌──────▼──────┐
│ tier=free ││ tier=pro ││tier=premium │
└──────┬──────┘└─────┬─────┘└──────┬──────┘
│ │ │
┌───────────────┼─────────────┼─────────────┼───────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│Anonymous│ │ Free │ │ Pro │ │ Premium │ │ Admin │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Preview │ │ Full │ │ Full │ │ Full │ │ Full │
│ Only │ │ Access │ │ Access │ │ Access │ │ Access │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
5.3 Paywall Logic¶
interface PaywallResult {
accessible: boolean; // Can user see content at all
previewOnly: boolean; // Limited to preview
requiredTier: string; // Tier needed for full access
upgradeMessage?: string; // Suggested upgrade action
}
function checkPaywall(
content: { access_tier: 'free' | 'pro' | 'premium' },
userRole: UserRole,
permissions: Permission[]
): PaywallResult {
const tierLevel = { free: 0, pro: 1, premium: 2 };
const roleLevel = {
anonymous: -1, free: 0, pro: 1, premium: 2, admin: 3, service: 3
};
const contentLevel = tierLevel[content.access_tier];
const userLevel = roleLevel[userRole];
// Full access
if (userLevel >= contentLevel || permissions.includes(Permission.READ_PREMIUM_CONTENT)) {
return { accessible: true, previewOnly: false, requiredTier: content.access_tier };
}
// Preview access for authenticated users or anonymous with preview permission
if (permissions.includes(Permission.READ_PREVIEW_CONTENT)) {
return {
accessible: true,
previewOnly: true,
requiredTier: content.access_tier,
upgradeMessage: `Upgrade to ${content.access_tier} to access full content`,
};
}
// No access
return {
accessible: false,
previewOnly: false,
requiredTier: content.access_tier,
};
}
5.4 Content Transformation¶
For preview mode, content is truncated:
function applyPaywall(content: PaywalledContent, result: PaywallResult): PaywalledContent {
if (!result.previewOnly) return content;
const preview = { ...content };
// Truncate markdown to 30%
if (preview.content_md) {
const lines = preview.content_md.split('\n');
const previewLines = Math.ceil(lines.length * 0.3);
preview.content_md = lines.slice(0, previewLines).join('\n')
+ '\n\n---\n\n*[Content preview - upgrade to continue reading]*';
}
// Add paywall metadata
preview._paywall = {
previewOnly: true,
requiredTier: result.requiredTier,
upgradeMessage: result.upgradeMessage,
};
return preview;
}
6. Middleware Architecture¶
6.1 Domain API Middleware (Hono)¶
// middleware/protection.ts
interface ProtectionConfig {
requiredPermissions?: Permission[];
rateLimitCategory?: string;
skipRateLimit?: boolean;
allowAnonymous?: boolean;
}
function protectionMiddleware(config: ProtectionConfig = {}): MiddlewareHandler {
return async (c, next) => {
// 1. Extract user context from JWT
const userContext = extractUserContext(c.req.header('Authorization'));
// 2. Check authentication requirement
if (!config.allowAnonymous && userContext.role === UserRole.ANONYMOUS) {
return c.json({ error: { code: 'UNAUTHORIZED', message: 'Authentication required' }}, 401);
}
// 3. Check permissions
for (const permission of config.requiredPermissions || []) {
if (!userContext.permissions.includes(permission)) {
return c.json({ error: { code: 'FORBIDDEN', message: 'Insufficient permissions' }}, 403);
}
}
// 4. Apply rate limiting
if (!config.skipRateLimit && !userContext.permissions.includes(Permission.BYPASS_RATE_LIMITS)) {
const result = rateLimiter.check(
getClientIdentifier(c.req, userContext),
RATE_LIMITS[userContext.role][config.rateLimitCategory || 'default']
);
setRateLimitHeaders(c, result);
if (!result.allowed) {
return c.json({ error: { code: 'RATE_LIMITED', message: 'Too many requests' }}, 429);
}
}
// 5. Store context for route handlers
c.set('userContext', userContext);
await next();
};
}
6.2 Experience API Middleware (Next.js)¶
// middleware.ts
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Only process API routes
if (!pathname.startsWith('/api/')) {
return NextResponse.next();
}
// Get route configuration
const routeConfig = getRouteConfig(pathname);
// Extract user context
const userContext = extractUserContext(request.headers.get('authorization'));
// Check authentication
if (!routeConfig.allowAnonymous && userContext.role === UserRole.ANONYMOUS) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: 'Authentication required' }},
{ status: 401 }
);
}
// Check permissions
for (const permission of routeConfig.requiredPermissions || []) {
if (!userContext.permissions.includes(permission)) {
return NextResponse.json(
{ error: { code: 'FORBIDDEN', message: 'Insufficient permissions' }},
{ status: 403 }
);
}
}
// Rate limiting
const identifier = getClientIdentifier(request, userContext);
const result = rateLimiter.check(identifier, routeConfig.rateLimitCategory);
const response = result.allowed
? NextResponse.next()
: NextResponse.json(
{ error: { code: 'RATE_LIMITED', message: 'Too many requests' }},
{ status: 429 }
);
// Set headers
setRateLimitHeaders(response, result);
response.headers.set('X-User-Role', userContext.role);
return response;
}
export const config = {
matcher: '/api/:path*',
};
6.3 Route Configuration¶
const ROUTE_CONFIG: Record<string, RouteProtectionConfig> = {
// Public discovery
'/api/discovery/*': {
allowAnonymous: true,
rateLimitCategory: 'content'
},
// Search (public but stricter limits)
'/api/search': {
allowAnonymous: true,
rateLimitCategory: 'search',
requiredPermissions: [Permission.BASIC_SEARCH],
},
'/api/search/advanced': {
allowAnonymous: false,
rateLimitCategory: 'search',
requiredPermissions: [Permission.ADVANCED_SEARCH],
},
// User routes (authenticated)
'/api/me/*': {
allowAnonymous: false,
requiredPermissions: [Permission.TRACK_PROGRESS],
},
// Premium features
'/api/analytics/*': {
allowAnonymous: false,
requiredPermissions: [Permission.ACCESS_ADVANCED_ANALYTICS],
},
};
7. Database Schema Design¶
7.1 Subscriptions Table¶
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Subscription details
plan VARCHAR(20) NOT NULL CHECK (plan IN ('free', 'pro', 'premium')),
status VARCHAR(20) NOT NULL CHECK (status IN ('active', 'canceled', 'past_due', 'trialing')),
-- Stripe integration
stripe_subscription_id VARCHAR(255),
stripe_customer_id VARCHAR(255),
stripe_price_id VARCHAR(255),
-- Billing period
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN DEFAULT false,
canceled_at TIMESTAMPTZ,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id)
);
-- Indexes
CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE INDEX idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
CREATE INDEX idx_subscriptions_period_end ON subscriptions(current_period_end);
-- RLS
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own subscription" ON subscriptions
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Service role manages subscriptions" ON subscriptions
FOR ALL USING (auth.jwt() ->> 'role' = 'service_role');
7.2 Content Tier Columns¶
-- Add access_tier to sparks
ALTER TABLE sparks ADD COLUMN access_tier VARCHAR(20) DEFAULT 'free'
CHECK (access_tier IN ('free', 'pro', 'premium'));
CREATE INDEX idx_sparks_access_tier ON sparks(access_tier);
-- Add access_tier to trails
ALTER TABLE trails ADD COLUMN access_tier VARCHAR(20) DEFAULT 'free'
CHECK (access_tier IN ('free', 'pro', 'premium'));
CREATE INDEX idx_trails_access_tier ON trails(access_tier);
7.3 Supabase Auth Hook¶
-- Function to add custom claims to JWT
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
claims jsonb;
user_role text;
subscription_plan text;
is_admin boolean;
BEGIN
-- Get user's subscription status
SELECT s.plan INTO subscription_plan
FROM subscriptions s
WHERE s.user_id = (event->>'user_id')::uuid
AND s.status = 'active'
AND s.current_period_end > NOW();
-- Determine role based on subscription
user_role := COALESCE(subscription_plan, 'free');
-- Check for admin role in user_profiles
SELECT (role = 'admin') INTO is_admin
FROM user_profiles
WHERE id = (event->>'user_id')::uuid;
IF COALESCE(is_admin, false) THEN
user_role := 'admin';
END IF;
-- Add claims to JWT
claims := event->'claims';
claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
claims := jsonb_set(claims, '{subscription_active}',
to_jsonb(subscription_plan IS NOT NULL));
claims := jsonb_set(claims, '{subscription_plan}',
COALESCE(to_jsonb(subscription_plan), 'null'::jsonb));
RETURN jsonb_set(event, '{claims}', claims);
END;
$$;
-- Grant execution to supabase_auth_admin
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook TO supabase_auth_admin;
REVOKE EXECUTE ON FUNCTION public.custom_access_token_hook FROM PUBLIC;
8. Stripe Integration Design¶
8.1 Webhook Event Handling¶
// routes/webhooks/stripe.ts
const STRIPE_EVENTS = [
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
'invoice.payment_failed',
'invoice.payment_succeeded',
];
async function handleStripeWebhook(c: Context) {
const signature = c.req.header('stripe-signature');
const body = await c.req.text();
// Verify webhook signature
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature!,
c.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return c.json({ error: 'Invalid signature' }, 400);
}
// Handle event
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionChange(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
}
return c.json({ received: true });
}
8.2 Subscription Sync Flow¶
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Stripe │────▶│ Webhook │────▶│ subscriptions│────▶│ Auth Hook │
│ Event │ │ Handler │ │ Table │ │ JWT Claims │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ │ │
▼ ▼ ▼
Verify Signature UPDATE/INSERT On Next Login:
Extract metadata subscription row user_role updated
8.3 Plan Mapping¶
const STRIPE_PLAN_MAPPING: Record<string, UserRole> = {
'price_pro_monthly': UserRole.PRO,
'price_pro_yearly': UserRole.PRO,
'price_premium_monthly': UserRole.PREMIUM,
'price_premium_yearly': UserRole.PREMIUM,
};
function mapStripePlanToRole(priceId: string): UserRole {
return STRIPE_PLAN_MAPPING[priceId] || UserRole.FREE;
}
9. Module Structure¶
9.1 Directory Layout¶
core/
├── shared/
│ └── src/
│ ├── index.ts # Main exports
│ └── protection/
│ ├── index.ts # Module exports
│ ├── types.ts # TypeScript types
│ ├── roles.ts # Role/permission definitions
│ ├── config.ts # Configuration constants
│ ├── role-resolver.ts # JWT → UserContext
│ ├── rate-limiter.ts # Rate limiter implementation
│ ├── paywall.ts # Paywall logic
│ └── audit.ts # Audit logging
│
├── domain/
│ └── api/
│ ├── middleware/
│ │ ├── index.ts # Middleware exports
│ │ ├── protection.ts # Main protection middleware
│ │ └── paywall.ts # Paywall middleware
│ └── routes/
│ ├── utils.ts # Updated with ProtectionContext
│ └── webhooks/
│ └── stripe.ts # Stripe webhook handler
│
├── experience/
│ └── src/
│ ├── middleware.ts # Next.js Edge Middleware
│ └── lib/
│ └── protection/
│ ├── index.ts # Protection utilities
│ ├── rate-limiter.ts # Rate limiter instance
│ └── config.ts # Route configurations
│
└── supabase/
└── migrations/
├── YYYYMMDD_add_subscriptions.sql
├── YYYYMMDD_add_access_tier.sql
└── YYYYMMDD_auth_hook.sql
9.2 Module Exports¶
// shared/src/protection/index.ts
// Types
export type {
UserRole,
Permission,
UserContext,
RateLimitConfig,
RateLimitResult,
PaywallResult,
} from './types';
// Constants
export { ROLE_PERMISSIONS } from './roles';
export { RATE_LIMITS, PROTECTION_CONFIG } from './config';
// Functions
export { resolveUserContext } from './role-resolver';
export { MemoryRateLimiter } from './rate-limiter';
export { checkPaywall, applyPaywall } from './paywall';
export { createAuditLog, AuditEventType } from './audit';
10. Security Considerations¶
10.1 JWT Validation¶
// Token validation rules
const JWT_VALIDATION = {
// Required claims
requiredClaims: ['sub', 'aud', 'exp', 'iss'],
// Expected values
audience: 'authenticated',
issuerPattern: /https:\/\/.*\.supabase\.co\/auth\/v1/,
// Clock tolerance for exp/nbf claims
clockTolerance: 60, // seconds
// Reject tokens older than
maxAge: 3600, // 1 hour (matches Supabase default)
};
function validateJWT(token: string, supabaseUrl: string): boolean {
try {
const [, payloadB64] = token.split('.');
const payload = JSON.parse(atob(payloadB64));
// Check expiration
if (payload.exp < Math.floor(Date.now() / 1000) - JWT_VALIDATION.clockTolerance) {
return false;
}
// Check issuer
if (!JWT_VALIDATION.issuerPattern.test(payload.iss)) {
return false;
}
// Check audience
if (payload.aud !== JWT_VALIDATION.audience) {
return false;
}
return true;
} catch {
return false;
}
}
10.2 Rate Limit Bypass Prevention¶
const SECURITY_CHECKS = {
// Block suspicious IP patterns
blockPatterns: [
/^127\./, // Localhost spoofing
/^0\./, // Invalid IPs
/^192\.168\./, // Private network (shouldn't reach public)
/^10\./, // Private network
],
// Detect rapid bursts
burstDetection: {
threshold: 50, // requests
windowMs: 1000, // 1 second
},
// Header validation
requiredHeaders: ['user-agent'],
};
function isRequestSuspicious(request: Request, identifier: string): boolean {
// Check for blocked IP patterns
if (identifier.startsWith('ip:')) {
const ip = identifier.slice(3);
for (const pattern of SECURITY_CHECKS.blockPatterns) {
if (pattern.test(ip)) {
return true;
}
}
}
// Check for missing required headers
for (const header of SECURITY_CHECKS.requiredHeaders) {
if (!request.headers.get(header)) {
return true;
}
}
return false;
}
10.3 Audit Logging¶
enum AuditEventType {
AUTH_SUCCESS = 'auth.success',
AUTH_FAILURE = 'auth.failure',
RATE_LIMITED = 'rate_limit.exceeded',
PERMISSION_DENIED = 'permission.denied',
PAYWALL_BLOCKED = 'paywall.blocked',
SUSPICIOUS_ACTIVITY = 'security.suspicious',
}
interface AuditLogEntry {
timestamp: string;
eventType: AuditEventType;
userId?: string;
clientIp: string;
userAgent?: string;
path: string;
method: string;
statusCode?: number;
details?: Record<string, unknown>;
requestId: string;
}
function createAuditLog(context: Context, eventType: AuditEventType, details?: object): void {
const entry: AuditLogEntry = {
timestamp: new Date().toISOString(),
eventType,
userId: context.get('userContext')?.id,
clientIp: context.req.header('x-forwarded-for')?.split(',')[0] || 'unknown',
userAgent: context.req.header('user-agent'),
path: context.req.path,
method: context.req.method,
requestId: context.get('requestId'),
details,
};
// Structured log output (captured by logging infrastructure)
console.log(JSON.stringify({ type: 'audit', ...entry }));
}
11. API Response Patterns¶
11.1 Success Responses¶
// Standard success
{
"data": { ... },
"meta": {
"page": 1,
"per_page": 20,
"total": 100
}
}
// With paywall metadata
{
"data": {
"id": "spark-uuid",
"title": "Advanced Topic",
"content_html": "<preview content>",
"_paywall": {
"previewOnly": true,
"requiredTier": "pro",
"upgradeMessage": "Upgrade to Pro for full access"
}
}
}
11.2 Error Responses¶
// Authentication error (401)
{
"error": {
"code": "UNAUTHORIZED",
"message": "Authentication required"
}
}
// Permission error (403)
{
"error": {
"code": "FORBIDDEN",
"message": "Insufficient permissions",
"required": "access:spaced_repetition",
"upgrade": "Consider upgrading to Pro"
}
}
// Rate limit error (429)
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests",
"retryAfter": 45
}
}
11.3 Response Headers¶
# All responses
X-Request-Id: uuid-here
X-User-Role: pro
# Rate limited responses
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1736496060
# 429 responses only
Retry-After: 45
12. Configuration Reference¶
12.1 Protection Configuration¶
const PROTECTION_CONFIG = {
// Feature flag
enabled: true,
// JWT settings
jwt: {
issuer: 'supabase',
audience: 'authenticated',
clockTolerance: 60,
},
// Rate limit defaults
rateLimit: {
defaultWindowMs: 60_000,
cleanupIntervalMs: 60_000,
maxStoreEntries: 10_000,
},
// Paywall settings
paywall: {
previewPercentage: 0.3, // 30% preview
premiumContentMarker: 'premium',
},
// Feature limits per tier
features: {
anonymous: { maxSearchResults: 10, canTrackProgress: false },
free: { maxSearchResults: 50, maxJourneys: 3, canTrackProgress: true },
pro: { maxSearchResults: 100, maxJourneys: 20, canTrackProgress: true },
premium: { maxSearchResults: Infinity, maxJourneys: Infinity },
},
};
12.2 Environment Variables¶
| Variable | Layer | Required | Description |
|---|---|---|---|
SUPABASE_URL |
Both | Yes | Supabase project URL |
SUPABASE_ANON_KEY |
Both | Yes | Supabase anonymous key |
SUPABASE_SERVICE_ROLE_KEY |
Domain | Yes | For subscription sync |
SERVICE_AUTH_SECRET |
Both | Yes | Shared secret for service-to-service auth |
DEV_SKIP_SERVICE_AUTH |
Domain | No | Skip service auth in local dev (set to "true") |
STRIPE_SECRET_KEY |
Domain | Yes | Stripe API key |
STRIPE_WEBHOOK_SECRET |
Domain | Yes | Webhook signature verification |
UPSTASH_REDIS_REST_URL |
Experience | Optional | Upstash Redis HTTP URL for advanced rate limiting |
UPSTASH_REDIS_REST_TOKEN |
Experience | Optional | Upstash Redis authentication token |
13. Design Decisions¶
13.1 Confirmed Decisions¶
| Decision | Choice | Rationale |
|---|---|---|
| Rate Limit Location | Vercel WAF/Edge | Serverless functions can't reliably maintain in-memory state |
| Service Authentication | X-Service-Auth header | Prevents direct access to Domain APIs; enforces Experience layer as gateway |
| Payment Provider | Stripe | Industry standard, robust webhook system |
| Rate Limit Response | Block completely (429) | Industry standard, predictable, includes Retry-After header |
| Audit Logging | Structured logs to stdout | Captured by Vercel/Supabase logging, no DB overhead |
| Role Storage | JWT custom claims | No DB lookup needed, role resolved at token issue time |
| Preview Percentage | 30% | Balance between tease and value |
| Anonymous Identification | IP-based (at Vercel Edge) | WAF can rate limit by IP without application code |
| Public API Access | Via Experience layer only | Service auth ensures all traffic goes through Vercel |
| In-Memory Rate Limiting | Removed | Was unreliable in serverless; replaced by Vercel WAF |
13.2 Trade-offs¶
| Trade-off | Chosen Approach | Alternative | Why |
|---|---|---|---|
| Rate limit accuracy | Multi-layer protection | Single-layer in-memory | Serverless requires external state for reliability |
| Rate limiter latency | Upstash Redis (~5-10ms) | In-memory (0ms) | Reliability over latency; still fast enough |
| Role refresh latency | On next login | Real-time sync | Simpler; subscription changes aren't instant anyway |
| Paywall enforcement | API layer | Database RLS | More flexibility for preview logic |
| Permission model | Flat permissions | Hierarchical RBAC | Simpler; no complex inheritance needed |
| Cost vs reliability | External Redis service | In-memory only | Pay for reliability at scale |
Appendix A: Quick Reference¶
A.1 Rate Limit Headers¶
X-RateLimit-Limit: Max requests per window
X-RateLimit-Remaining: Remaining requests
X-RateLimit-Reset: Unix timestamp when window resets
Retry-After: Seconds until next request allowed (429 only)
A.2 Error Codes¶
UNAUTHORIZED - Authentication required (401)
FORBIDDEN - Insufficient permissions (403)
RATE_LIMITED - Too many requests (429)
PAYWALL_BLOCKED - Content requires upgrade (403)
A.3 User Roles¶
A.4 Content Tiers¶
A.5 Anonymous Rate Limiting Quick Reference¶
How anonymous requests are rate limited without API tokens:
1. Client makes HTTP request (no Authorization header)
2. CDN/Proxy adds client IP to X-Forwarded-For header
3. Edge function extracts IP from headers
4. Rate limit checked via Upstash Redis (production) or best-effort in-memory
5. Response includes X-RateLimit-* headers
IP Extraction Priority:
1. CF-Connecting-IP (Cloudflare)
2. X-Forwarded-For (first IP)
3. X-Real-IP (nginx)
4. X-Client-IP (fallback)
Anonymous Rate Limits:
Example Anonymous Request:
# No auth required - rate limited by IP
curl https://project.supabase.co/functions/v1/browse/domains
# Response headers show rate limit status
# X-RateLimit-Limit: 20
# X-RateLimit-Remaining: 19
# X-RateLimit-Reset: 1704067200
A.6 Service Authentication Quick Reference¶
How service authentication works:
1. Client makes request to Experience layer (Vercel)
2. Vercel WAF applies rate limiting at edge
3. Next.js middleware validates request
4. Domain client adds X-Service-Auth header with shared secret
5. Domain API verifies X-Service-Auth before processing
6. Direct requests to Supabase functions return 403
Environment Variables Required:
# Experience layer (.env.local)
SERVICE_AUTH_SECRET=your-secret-here
# Domain layer (Supabase secrets)
SERVICE_AUTH_SECRET=your-secret-here
DEV_SKIP_SERVICE_AUTH=true # For local development
Generate a secret:
A.7 Protection Layers Checklist¶
For Production Deployment:
✅ Configure SERVICE_AUTH_SECRET in both layers
✅ Configure Vercel WAF rate limiting in dashboard
✅ Enable Supabase DDoS protection (automatic)
✅ Set verify_jwt = true for protected functions
✅ Verify direct Supabase access returns 403
✅ Test via Experience layer succeeds
Protection Layers (Defense in Depth):