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
- Authentication
- User Roles & Subscriptions
- Rate Limiting
- Content Tiers & Paywall
- API Endpoints
- Error Responses
- Testing
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:
- Admin check: Is user in
admin_userstable? →admin - Subscription check: Active subscription plan →
premium,pro, orfree - 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:
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 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 | 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
apikeyheader 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_hookfunction 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:
Verifying Auth Hook¶
Run the verification function in your database:
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¶
- Never expose service role key to client applications
- Validate tokens server-side - don't trust client-provided role claims
- Use HTTPS for all API communications
- Implement token refresh to minimize exposure window
- Monitor rate limit abuse for potential attacks
- 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