Skip to content

API Protection Guide

This guide covers the API protection system for the micro-learning platform, including authentication, authorization, rate limiting, and content tier access.

Table of Contents


Architecture Overview

The platform uses a two-layer API architecture:

┌─────────────────────────────────────────────────────────────┐
│                     Client Application                       │
└─────────────────────────┬───────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│              Experience API (Vercel BFF)                     │
│                   /api/* routes                              │
│   - Next.js Edge Middleware                                  │
│   - Rate limiting (first layer)                              │
│   - User context resolution                                  │
└─────────────────────────┬───────────────────────────────────┘
                          │ Forwards headers
┌─────────────────────────────────────────────────────────────┐
│              Domain API (Supabase Edge)                      │
│              /functions/v1/* routes                          │
│   - Hono middleware                                          │
│   - Rate limiting (second layer)                             │
│   - Paywall enforcement                                      │
└─────────────────────────────────────────────────────────────┘

Key Components

Layer Technology Purpose
Experience API Next.js on Vercel BFF for frontend, SSR, caching
Domain API Deno/Hono on Supabase Edge Core business logic, data access
Auth Supabase Auth JWT tokens with custom claims
Database PostgreSQL (Supabase) Subscriptions, content tiers

Authentication

Obtaining an Access Token

Use Supabase Auth to authenticate users and obtain JWT tokens.

Sign Up

curl -X POST 'https://YOUR_PROJECT.supabase.co/auth/v1/signup' \
  -H 'apikey: YOUR_ANON_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "user@example.com",
    "password": "securepassword123"
  }'

Sign In

curl -X POST 'https://YOUR_PROJECT.supabase.co/auth/v1/token?grant_type=password' \
  -H 'apikey: YOUR_ANON_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "user@example.com",
    "password": "securepassword123"
  }'

Response:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "user": {
    "id": "user-uuid",
    "email": "user@example.com"
  }
}

Using the Access Token

Include the token in the Authorization header:

curl -X GET 'https://YOUR_PROJECT.supabase.co/functions/v1/me' \
  -H 'apikey: YOUR_ANON_KEY' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'

Token Refresh

Access tokens expire after 1 hour. Use the refresh token to obtain a new access token:

curl -X POST 'https://YOUR_PROJECT.supabase.co/auth/v1/token?grant_type=refresh_token' \
  -H 'apikey: YOUR_ANON_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "refresh_token": "YOUR_REFRESH_TOKEN"
  }'

JWT Custom Claims

The platform uses a Supabase Auth Hook to inject custom claims into JWT tokens:

Claim Type Description
user_role string User's role: free, pro, premium, admin
subscription_plan string Current subscription plan
subscription_active boolean Whether subscription is active

Example decoded token payload:

{
  "sub": "user-uuid",
  "email": "user@example.com",
  "user_role": "pro",
  "subscription_plan": "pro",
  "subscription_active": true,
  "exp": 1736496000
}


User Roles & Subscriptions

Role Hierarchy

admin     (highest privileges)
premium   (all content + higher limits)
pro       (pro content + enhanced limits)
free      (free content + basic limits)
anonymous (public content only)

Subscription Plans

Plan Features Rate Limit
Free Free content, basic features 60 req/min
Pro Pro + Free content, enhanced features 200 req/min
Premium All content, premium features 500 req/min
Admin Full access, admin endpoints 1000 req/min

Role Resolution

The user's role is determined by:

  1. Admin check: Is user in admin_users table? → admin
  2. Subscription check: Active subscription plan → premium, pro, or free
  3. Default: No subscription → free

Rate Limiting

Rate Limit Headers

All API responses include rate limit headers:

Header Description
X-RateLimit-Limit Maximum requests per window
X-RateLimit-Remaining Requests remaining in current window
X-RateLimit-Reset Unix timestamp when window resets
Retry-After Seconds to wait (only on 429 responses)

Rate Limits by Role

Role Requests/Minute Window Size
Anonymous 20 60s
Free 60 60s
Pro 200 60s
Premium 500 60s
Admin 1000 60s

Rate Limit Response

When rate limited, you receive a 429 Too Many Requests:

{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Too many requests. Please wait before retrying.",
    "retryAfter": 45
  }
}

Headers:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1736496060
Retry-After: 45


Content Tiers & Paywall

Content Access Tiers

Content (sparks, trails, concepts) has an access_tier field:

Tier Accessible By
free All users (including anonymous)
pro Pro, Premium, Admin users
premium Premium, Admin users

Paywall Response

When accessing content above your tier:

{
  "error": {
    "code": "PAYMENT_REQUIRED",
    "message": "This content requires a Pro subscription",
    "requiredTier": "pro",
    "currentTier": "free"
  }
}

Status Code: 402 Payment Required

Checking Content Access

The X-User-Role header in responses indicates the user's current role:

X-User-Role: free

API Endpoints

Public Endpoints (No Auth Required)

Method Endpoint Description
GET /health Health check
GET /domains List all domains
GET /domains/:slug Get domain by slug
GET /discovery/featured Featured content
GET /search Global search

Authenticated Endpoints

Method Endpoint Description Min Role
GET /me Current user profile free
GET /me/progress Learning progress free
GET /me/bookmarks User bookmarks free
GET /me/events User activity free
POST /me/events Track event free

Admin Endpoints

Method Endpoint Description Required Role
GET /admin/stats System statistics admin

Error Responses

Standard Error Format

{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable message",
    "details": {}
  }
}

Error Codes

Code Status Description
UNAUTHORIZED 401 Missing or invalid authentication
FORBIDDEN 403 Insufficient permissions
PAYMENT_REQUIRED 402 Content requires higher subscription
RATE_LIMIT_EXCEEDED 429 Too many requests
NOT_FOUND 404 Resource not found
BAD_REQUEST 400 Invalid request parameters
INTERNAL_ERROR 500 Server error

Testing

Running E2E Tests

The E2E test suite validates all protection mechanisms:

# Navigate to test directory
cd tools/scripts/test/protection

# Run against local Supabase
deno run --allow-net --allow-env --allow-read e2e-protection-test.ts

# Run against production
SUPABASE_URL=https://YOUR_PROJECT.supabase.co \
SUPABASE_ANON_KEY=your-anon-key \
deno run --allow-net --allow-env --allow-read e2e-protection-test.ts

# Run with verbose output
deno run --allow-net --allow-env --allow-read e2e-protection-test.ts --verbose

# Run specific test suite
deno run --allow-net --allow-env --allow-read e2e-protection-test.ts --suite=auth
deno run --allow-net --allow-env --allow-read e2e-protection-test.ts --suite=rate
deno run --allow-net --allow-env --allow-read e2e-protection-test.ts --suite=paywall

Test Suites

Suite Flag Description
Public Routes --suite=public Tests public endpoint access
Authentication --suite=auth Tests token validation
Rate Limiting --suite=rate Tests rate limit enforcement
Authorization --suite=authz Tests role-based access
Paywall --suite=paywall Tests content tier access
User Routes --suite=user Tests authenticated user endpoints

Test Users

The test suite creates users for each role:

Role Email Password
Free test-free@example.com test123456
Pro test-pro@example.com test123456
Premium test-premium@example.com test123456
Admin test-admin@example.com test123456

Environment Variables

Variable Description
SUPABASE_URL Supabase project URL
SUPABASE_ANON_KEY Supabase anonymous key
SUPABASE_SERVICE_ROLE_KEY Service role key (for user setup)

Client Integration Examples

JavaScript/TypeScript

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

// Sign in
const { data: authData, error: authError } = await supabase.auth.signInWithPassword({
  email: 'user@example.com',
  password: 'password123'
});

// Make authenticated API call
const response = await fetch(`${SUPABASE_URL}/functions/v1/me`, {
  headers: {
    'apikey': SUPABASE_ANON_KEY,
    'Authorization': `Bearer ${authData.session.access_token}`
  }
});

// Check rate limit headers
console.log('Remaining:', response.headers.get('X-RateLimit-Remaining'));

React Hook Example

import { useSession } from '@supabase/auth-helpers-react';

function useProtectedApi() {
  const session = useSession();

  const callApi = async (path: string, options?: RequestInit) => {
    const headers = new Headers(options?.headers);
    headers.set('apikey', process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);

    if (session?.access_token) {
      headers.set('Authorization', `Bearer ${session.access_token}`);
    }

    const response = await fetch(
      `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1${path}`,
      { ...options, headers }
    );

    // Handle rate limiting
    if (response.status === 429) {
      const retryAfter = response.headers.get('Retry-After');
      throw new Error(`Rate limited. Retry after ${retryAfter}s`);
    }

    // Handle paywall
    if (response.status === 402) {
      const data = await response.json();
      throw new Error(`Upgrade required: ${data.error.requiredTier}`);
    }

    return response;
  };

  return { callApi, isAuthenticated: !!session };
}

Troubleshooting

Common Issues

"401 Unauthorized" on all requests

  • Verify your apikey header is set correctly
  • Check that the access token hasn't expired
  • Ensure you're using the correct Supabase URL

"429 Too Many Requests" frequently

  • Check your current tier's rate limit
  • Implement exponential backoff
  • Consider upgrading to a higher tier

JWT claims missing user_role

  • Verify the Auth Hook is enabled in Supabase Dashboard
  • Check that the custom_access_token_hook function exists
  • User may need to sign out and sign in again to get updated claims

Content returns 402 but user has subscription

  • JWT token may have stale claims; refresh the token
  • Verify subscription status is 'active' in database
  • Check that Auth Hook is running on token refresh

Debug Mode

Enable verbose logging in tests:

deno run --allow-net --allow-env --allow-read e2e-protection-test.ts --verbose

Verifying Auth Hook

Run the verification function in your database:

SELECT * FROM verify_auth_hook_setup();

Expected output:

check_name                  | status | details
----------------------------|--------|------------------------------------------
Hook function exists        | PASS   | Function public.custom_access_token_hook...
Subscriptions table exists  | PASS   | Table public.subscriptions should exist
Admin users table exists    | PASS   | Table public.admin_users should exist
Grants configured           | PASS   | Function should be granted to supabase...


Security Considerations

  1. Never expose service role key to client applications
  2. Validate tokens server-side - don't trust client-provided role claims
  3. Use HTTPS for all API communications
  4. Implement token refresh to minimize exposure window
  5. Monitor rate limit abuse for potential attacks
  6. Log security events via the audit system

Support

For issues or questions: - Check the Troubleshooting section - Review test output for specific failures - Check Supabase Edge Function logs in the Dashboard