Authentication & Authorization Guide¶
Overview¶
This guide provides comprehensive documentation on how authentication and authorization work in the Micro-Learning Platform API. Understanding these mechanisms is essential for successful integration with the API.
Purpose and Scope¶
The API implements a four-layer protection stack:
- Service Authentication —
X-Service-Authheader for backend services (Cloudflare Workers, ISR/SSR) - JWT Authentication —
Authorization: Bearer <token>for user sessions - Role-Based Access Control (RBAC) — 6 roles with 16 granular permissions
- Row Level Security (RLS) — PostgreSQL policies enforcing data boundaries
When Authentication is Required¶
- All API endpoints require authentication (user JWT or service auth)
- User endpoints (
/me/*) additionally requireTRACK_PROGRESSpermission (only available via user JWT, not service auth) - Health check endpoints (
/health) are exempt from all authentication
Four-Layer Protection Stack¶
Layer 1: Service Authentication (X-Service-Auth)¶
Backend services (Cloudflare Workers performing ISR/SSR) authenticate using a
shared secret in the X-Service-Auth header. This is checked by the
serviceAuthMiddleware applied globally to all routes.
When a request has no user JWT but carries a valid X-Service-Auth header, the
protection middleware promotes the request from ANONYMOUS to the SERVICE
role. See Service-to-Service Guide for
integration details.
Layer 2: JWT Authentication (Authorization: Bearer)¶
Client applications authenticate using Supabase Auth JWT tokens. The token is
passed in the Authorization: Bearer <token> header.
Important: All edge functions are deployed with verify_jwt = false in
supabase/config.toml. JWT verification is handled entirely at the application
level by the protection middleware, not by the Supabase platform. This gives the
application full control over authentication logic including the service auth
fallback.
How JWT Resolution Works¶
- The protection middleware extracts the
Authorizationheader - The JWT is decoded (without calling Supabase Auth API) using
resolveUserContextFromHeader() - Custom claims (
user_role,subscription_plan,subscription_active) are read from the token payload - A
UserContextis created with the resolved role and permissions - If no JWT is present, the context defaults to
ANONYMOUS
Custom JWT Claims¶
A PostgreSQL Auth Hook (custom_access_token_hook) injects custom claims into
JWT tokens at issuance:
{
"sub": "user-uuid",
"aud": "authenticated",
"exp": 1740000000,
"user_role": "pro",
"subscription_plan": "pro",
"subscription_active": true
}
These custom claims determine the user's role without requiring a database lookup on each request.
Layer 3: Role-Based Access Control (RBAC)¶
After JWT resolution, the protection middleware checks the user's role and permissions against route requirements.
User Roles (6 roles)¶
| Role | Description | Hierarchy Level |
|---|---|---|
ANONYMOUS |
Unauthenticated — limited preview | 0 |
FREE |
Authenticated without subscription | 1 |
PRO |
Standard paid subscription | 2 |
PREMIUM |
Premium paid subscription | 3 |
ADMIN |
Platform administrator — full access | 4 |
SERVICE |
Service account for API-to-API calls | 4 |
Permissions (16 permissions)¶
| Permission | ANON | FREE | PRO | PREMIUM | ADMIN | SERVICE |
|---|---|---|---|---|---|---|
READ_PUBLIC_CONTENT |
Yes | Yes | Yes | Yes | Yes | Yes |
READ_PREVIEW_CONTENT |
Yes | Yes | Yes | Yes | Yes | Yes |
READ_FULL_CONTENT |
Yes | Yes | Yes | Yes | Yes | |
READ_PREMIUM_CONTENT |
Yes | Yes | Yes | Yes | ||
BASIC_SEARCH |
Yes | Yes | Yes | Yes | Yes | |
ADVANCED_SEARCH |
Yes | Yes | Yes | |||
UNLIMITED_SEARCH |
Yes | Yes | Yes | |||
TRACK_PROGRESS |
Yes | Yes | Yes | Yes | ||
CREATE_JOURNEY |
Yes | Yes | Yes | Yes | ||
ACCESS_SPACED_REPETITION |
Yes | Yes | Yes | |||
ACCESS_ADVANCED_ANALYTICS |
Yes | Yes | ||||
MANAGE_CONTENT |
Yes | |||||
MANAGE_USERS |
Yes | |||||
VIEW_ANALYTICS |
Yes | |||||
BYPASS_RATE_LIMITS |
Yes | Yes |
Middleware Resolution Order¶
The protectionMiddleware() resolves user context in this priority:
- Dev bypass (local dev only): If
DEV_BYPASS_AUTH=trueand on localhost, create a context usingDEV_USER_IDwith configurable role - JWT resolution: Decode
Authorization: Bearer <token>and resolve role from custom claims - Service auth fallback: If JWT resolution yields
ANONYMOUSAND the request has a validX-Service-Authheader, promote toSERVICEcontext
// Simplified middleware flow:
userContext = resolveUserContextFromHeader(authHeader);
if (userContext.role === UserRole.ANONYMOUS && hasValidServiceAuth(c)) {
userContext = createUserContext(UserRole.SERVICE, "service");
}
Route Protection Levels¶
The protection middleware supports several convenience factories:
| Factory | Config |
|---|---|
requireAuthentication() |
allowAnonymous: false — blocks ANONYMOUS role |
userRoute() |
Requires TRACK_PROGRESS permission |
eventRoute() |
Requires TRACK_PROGRESS + event rate limits |
searchRoute() |
Requires BASIC_SEARCH permission |
contentRoute() |
Content access with paywall checks |
publicRoute() |
allowAnonymous: true — allows all roles |
Layer 4: Row Level Security (RLS)¶
PostgreSQL RLS policies provide the final data boundary:
- Published content (domains, trails, concepts, sparks): Readable by
anonrole via RLS policies — no auth needed at database level - User data (journeys, learning events): Requires
auth.uid()match — enforced by RLS regardless of API middleware
When a user authenticates via JWT, the Authorization header is forwarded to
Supabase, and auth.uid() is set in the database context. When a service
authenticates via X-Service-Auth (no JWT), the Supabase client uses the anon
key, and RLS policies for published content apply normally.
Token Types and Extraction¶
JWT Token Structure¶
JWT tokens consist of three parts separated by dots (.):
Token Format¶
- Header: Contains token type and signing algorithm
- Payload: Contains claims (user ID, email, roles, expiration, etc.)
- Signature: Used to verify the token hasn't been tampered with
Standard Claims¶
JWT tokens include standard claims:
iss(issuer): Supabase Auth URLsub(subject): User UUIDaud(audience): Usually "authenticated"exp(expiration): Unix timestamp when token expiresiat(issued at): Unix timestamp when token was issuedemail: User's email addressrole: Supabase role (usually "authenticated")
Custom Claims (Auth Hook)¶
The custom_access_token_hook PostgreSQL function adds:
user_role: Application role (free,pro,premium,admin)subscription_plan: Current plan name ornullsubscription_active: Whether subscription is active
These claims are used by resolveUserContextFromHeader() to determine the
user's role without a database query.
How to Decode and Inspect Tokens¶
You can decode JWT tokens (without verification) using online tools or libraries:
- Visit https://jwt.io
- Paste your token
- View the decoded header and payload
Note: Never share your tokens publicly. Decoding is for debugging purposes only.
Obtaining Tokens¶
Local Development¶
For local development, use the automated setup:
This seeds test users into the local Supabase instance. You can then authenticate via the Supabase Auth API:
curl -X POST 'http://127.0.0.1:54321/auth/v1/token?grant_type=password' \
-H "apikey: <SUPABASE_ANON_KEY>" \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "password": "password"}'
Production¶
For production tokens, authenticate via the Supabase Auth API:
curl -X POST 'https://<project-ref>.supabase.co/auth/v1/token?grant_type=password' \
-H "apikey: YOUR_ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "your@email.com", "password": "your-password"}'
Response includes:
{
"access_token": "eyJhbGci...",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "...",
"user": { "..." }
}
Using Supabase Client Libraries¶
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// Sign in
const { data, error } = await supabase.auth.signInWithPassword({
email: "user@example.com",
password: "password",
});
// Access token is available in the session
const token = data.session?.access_token;
Token Lifecycle¶
Token Expiration¶
- Default expiration: 1 hour (3600 seconds)
- Configurable: Set via
jwt_expiryinsupabase/config.toml - Expired tokens: Return
401 Unauthorizederrors
Refresh Token Mechanism¶
Supabase provides refresh tokens to obtain new access tokens without re-authenticating:
Token Refresh Strategies¶
Proactive Refresh: Refresh tokens before they expire:
const expiresAt = session.expires_at;
const now = Math.floor(Date.now() / 1000);
if (expiresAt - now < 300) {
// 5 minutes remaining
await supabase.auth.refreshSession();
}
Reactive Refresh: Refresh when receiving 401 errors:
try {
await apiCall();
} catch (error) {
if (error.status === 401) {
await supabase.auth.refreshSession();
await apiCall(); // Retry with new token
}
}
Environment-Specific Tokens¶
- Tokens from one environment cannot be used in another
- Each environment has its own JWT secret
- Tokens must be obtained from the environment where they'll be used
| Environment | Supabase URL | Token Source |
|---|---|---|
| Local Dev | http://127.0.0.1:54321 |
Local Supabase Auth |
| Production | https://<ref>.supabase.co |
Production Auth |
Route Classification¶
Authentication Method Support¶
All API endpoints require authentication via one of two methods:
| Method | Header | Use Case |
|---|---|---|
| User JWT | Authorization: Bearer <token> |
Client applications |
| Service Auth | X-Service-Auth: <secret> |
Server-to-server (ISR/SSR) |
Route Protection Summary¶
| Route Category | Middleware | User JWT | Service Auth | Extra Requirements |
|---|---|---|---|---|
/graph/* |
requireAuthentication() |
Yes | Yes | — |
/content/* |
requireAuthentication() |
Yes | Yes | — |
/home |
requireAuthentication() |
Yes | Yes | — |
/journeys/* |
requireAuthentication() |
Yes | Yes | — |
/search/* |
requireAuthentication() |
Yes | Yes | — |
/metadata/* |
requireAuthentication() |
Yes | Yes | — |
/snapshots/* |
requireAuthentication() |
Yes | Yes | — |
/me/* |
userRoute() |
Yes | No | TRACK_PROGRESS perm |
/me/events/* |
eventRoute() |
Yes | No | TRACK_PROGRESS perm |
Complete Endpoint Reference¶
Graph Endpoints (/graph/*)¶
| Method | Path | Description |
|---|---|---|
| GET | /graph/domains |
List domains |
| GET | /graph/domains/{slug} |
Get domain details |
| GET | /graph/trails |
List trails |
| GET | /graph/trails/{slug} |
Get trail details |
| GET | /graph/concepts |
List concepts |
| GET | /graph/concepts/{slug} |
Get concept details |
| GET | /graph/sparks |
List sparks (metadata) |
| GET | /graph/sparks/{slug} |
Get spark metadata |
| GET | /graph/beacons |
List beacons |
| GET | /graph/beacons/{slug} |
Get beacon details |
| GET | /graph/explore |
Faceted mixed-type discovery |
| GET | /graph/path |
Learning path calculation |
| GET | /graph/breadcrumb/{type}/{slug} |
Navigation breadcrumbs |
| GET | /graph/links/sparks |
Spark prerequisite links |
Content Endpoints (/content/*)¶
| Method | Path | Description |
|---|---|---|
| GET | /content/sparks/{slug} |
Get lesson content |
| GET | /content/sparks/{slug}/versions |
List content versions |
| GET | /content/sparks/{slug}/versions/{v} |
Get specific version |
Home Endpoint (/home)¶
| Method | Path | Description |
|---|---|---|
| GET | /home |
Homepage aggregation |
Journey Catalog Endpoints (/journeys/*)¶
| Method | Path | Description |
|---|---|---|
| GET | /journeys |
List journey catalog |
| GET | /journeys/{slug} |
Get journey landing page |
Search Endpoints (/search/*)¶
| Method | Path | Description |
|---|---|---|
| GET | /search |
Full-text search |
| GET | /search/autocomplete |
Autocomplete suggestions |
Metadata Endpoints (/metadata/*)¶
| Method | Path | Description |
|---|---|---|
| GET | /metadata |
Static enumerations |
Snapshot Endpoints (/snapshots/*)¶
| Method | Path | Description |
|---|---|---|
| GET | /snapshots |
List snapshots |
| GET | /snapshots/current |
Get current snapshot |
| GET | /snapshots/{id} |
Get snapshot by ID |
User Endpoints (/me/*) — User JWT Only¶
| Method | Path | Description |
|---|---|---|
| GET | /me |
User profile |
| GET | /me/stats |
Learning statistics |
| GET | /me/milestones |
Achievements |
| GET | /me/journeys |
Trail progress list |
| GET | /me/journeys/{id} |
Journey details |
| GET | /me/events |
Learning events |
| GET | /me/events/{sparkId} |
Spark activity |
Authentication Flow¶
User JWT Flow (Client Applications)¶
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Client │────►│ Supabase │────►│ Edge │
│ Application │ │ Auth │ │ Functions │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ 1. Login/Signup │ │
│──────────────────────►│ │
│ │ │
│ 2. JWT Token │ │
│◄──────────────────────│ │
│ │ │
│ 3. API Request + Authorization: Bearer <JWT> │
│─────────────────────────────────────────────►│
│ │ │
│ │ 4. Resolve Context │
│ │ (decode JWT claims) │
│ │ │
│ 5. Response (scoped to user role) │
│◄─────────────────────────────────────────────│
- User authenticates via Supabase Auth (OAuth, email/password)
- Supabase returns JWT access token with custom claims
- Client includes token in
Authorization: Bearer <token>header - Protection middleware decodes JWT, resolves role from custom claims
- Route handler executes with user context (role, permissions)
Service Auth Flow (Server-to-Server)¶
┌─────────────────┐ ┌─────────────────┐
│ Cloudflare │ X-Service-Auth: <secret> │ Edge │
│ Worker (ISR) │────────────────────────────►│ Functions │
└─────────────────┘ (no Authorization header) └─────────────────┘
│ │
│ 1. Request with X-Service-Auth │
│──────────────────────────────────────────────►│
│ │
│ 2. JWT resolves to ANONYMOUS │
│ 3. Service auth check → valid │
│ 4. Promote to SERVICE role │
│ │
│ 5. Response (SERVICE permissions) │
│◄──────────────────────────────────────────────│
- Service sends request with
X-Service-Auth: <shared-secret>header - JWT resolution finds no Bearer token →
ANONYMOUS - Service auth fallback validates
X-Service-Auth→ promotes toSERVICE - Route handler executes with SERVICE context (read-only content access)
See Service-to-Service Guide for full details.
Token Validation Process¶
- Protection middleware extracts
Authorizationheader - JWT is decoded to extract custom claims (
user_role,subscription_plan,subscription_active) - Role is mapped:
mapPlanToRole()converts plan names toUserRoleenum - Permissions are looked up from
ROLE_PERMISSIONS[role] UserContextis stored in Hono context for route handlers
Error Handling at Each Level¶
Authentication errors (no valid JWT or service auth):
Status: 401 Unauthorized
Permission errors (authenticated but lacks required permission):
{
"error": {
"code": "FORBIDDEN",
"message": "Insufficient permissions",
"required": "track:progress"
}
}
Status: 403 Forbidden
Authorization Model¶
User-Specific Data Access¶
How User ID is Extracted from JWT Token¶
The resolveUserContextFromHeader() function:
- Decodes the JWT payload (base64)
- Extracts
subclaim as user ID - Extracts custom claims for role resolution
- Returns a
UserContextwith user ID, role, and permissions
User-Scoped Data Queries¶
User-specific endpoints filter by the authenticated user's ID:
const userContext = getUserContext(c);
const { data } = await supabase
.from("user_journeys")
.select("*")
.eq("user_id", userContext.id);
RLS Policies Enforce User-Specific Access¶
Database Row Level Security policies ensure users can only access their own data:
-- Users can only see their own journeys
CREATE POLICY "Users can only see their own journeys"
ON user_journeys
FOR SELECT
USING (auth.uid() = user_id);
Database RLS Integration¶
How RLS Interacts with Authentication¶
- User JWT requests: The
Authorizationheader is forwarded to Supabase, settingauth.uid()in the database context - Service auth requests: No JWT → Supabase client uses anon key → RLS
policies for
anonrole apply (published content readable, user data inaccessible) - RLS policies filter results based on the database role (
authenticatedoranon)
Public Read Access for Published Content¶
-- Public can read published domains (anon role)
CREATE POLICY "Public can read published domains"
ON domains
FOR SELECT
USING (is_published = true);
This allows both user JWT and service auth requests to read published content.
User-Specific Access for Personal Data¶
-- Users can access their own journeys (authenticated role only)
CREATE POLICY "Users can access their own journeys"
ON user_journeys
FOR ALL
USING (auth.uid() = user_id);
Service auth requests cannot access user-specific data because they use the anon
key (no auth.uid() set).
Service Role Key Usage (Admin Operations)¶
For server-side admin operations that bypass RLS:
// Use service role key (bypasses RLS)
const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
// This query bypasses RLS policies
const { data } = await adminClient.from("user_journeys").select("*");
Warning: The service role key should only be used server-side and never
exposed to clients. It is NOT the same as the SERVICE_AUTH_SECRET used for
service-to-service authentication.
Integration Guide¶
Client Application Integration¶
Web/Browser: Using Supabase JS Client¶
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// Sign in (OAuth or email/password)
const { data, error } = await supabase.auth.signInWithPassword({
email: "user@example.com",
password: "password",
});
// Make API call with token
const response = await fetch("/functions/v1/graph/domains", {
headers: {
Authorization: `Bearer ${data.session.access_token}`,
},
});
Mobile: Using Supabase Mobile SDKs¶
import { createClient } from "@supabase/supabase-js";
import * as SecureStore from "expo-secure-store";
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: {
storage: SecureStore,
autoRefreshToken: true,
persistSession: true,
},
});
await supabase.auth.signInWithPassword({
email: "user@example.com",
password: "password",
});
SDK Usage¶
import { bearerAuth, createClient } from "@musingly-ai/core";
const client = createClient({
baseUrl: "https://api.musingly.ai",
});
// Browse content (requires auth)
client.setAuth(bearerAuth("your-jwt-token"));
const { data: domains } = await client.domains.graphListDomains();
// User-specific features
const { data: profile } = await client.me.userGetProfile();
Service-to-Service Integration¶
For Cloudflare Workers and other backend services, see the dedicated Service-to-Service Guide.
Quick example:
const response = await fetch(
"https://<project-ref>.supabase.co/functions/v1/graph/domains",
{
headers: {
"X-Service-Auth": process.env.SERVICE_AUTH_SECRET,
},
},
);
Token Storage Best Practices¶
- Never commit tokens to version control
- Store tokens securely: Use environment variables or secure storage
- Refresh tokens proactively: Don't wait for expiration
- Handle token expiration: Implement retry logic with token refresh
Troubleshooting¶
Common Authentication Errors¶
"Authentication required" (401)¶
Symptoms:
401 Unauthorizedwith"code": "UNAUTHORIZED"- Request reaches protection middleware but has no valid auth
Causes:
- No
Authorizationheader and noX-Service-Authheader - JWT token is expired or malformed
- Service auth secret is incorrect
Solutions:
- Add
Authorization: Bearer <token>header (for user requests) - Add
X-Service-Auth: <secret>header (for service requests) - Refresh or obtain a new JWT token
- Verify the
SERVICE_AUTH_SECRETmatches between services
"Insufficient permissions" (403)¶
Symptoms:
403 Forbiddenwith"code": "FORBIDDEN"- Request is authenticated but lacks required permission
Causes:
- User's subscription tier doesn't grant the required permission
- Service auth trying to access
/me/*routes (lacksTRACK_PROGRESS) - Free user trying to access premium content
Solutions:
- Check the permission matrix above for the user's role
- For service auth, only access content endpoints (not
/me/*) - Upgrade subscription tier for additional permissions
Token Expiration Issues¶
Symptoms:
- Requests work initially but fail after ~1 hour
- 401 errors with previously valid tokens
Solutions:
- Implement proactive token refresh (before expiration)
- Handle
401errors by refreshing and retrying - Use Supabase client library's built-in auto-refresh
Environment Mismatch¶
Symptoms:
- Token works in one environment but not another
- 401 errors despite valid-looking tokens
Solutions:
- Verify token was obtained from the correct environment
- Check JWT secret matches the target environment
- Tokens from local dev cannot be used in production (and vice versa)
Service Auth Not Working¶
Symptoms:
X-Service-Authheader present but still getting 401- Service requests treated as anonymous
Solutions:
- Verify
SERVICE_AUTH_SECRETenvironment variable is set on edge functions - Check the secret value matches exactly (no trailing whitespace)
- Ensure header name is exactly
X-Service-Auth(case-sensitive) - Confirm no
Authorizationheader is also being sent (JWT takes precedence)
See Service-to-Service Guide for detailed troubleshooting.
Related Documentation¶
- Auth Implementation — OAuth flows, session management, database schema
- Service-to-Service Guide — Cloudflare Worker integration
- API Protection Architecture — Security model deep dive