Skip to content

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:

  1. Service AuthenticationX-Service-Auth header for backend services (Cloudflare Workers, ISR/SSR)
  2. JWT AuthenticationAuthorization: Bearer <token> for user sessions
  3. Role-Based Access Control (RBAC) — 6 roles with 16 granular permissions
  4. 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 require TRACK_PROGRESS permission (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.

Cloudflare Worker ──► X-Service-Auth: <secret> ──► Edge Function ──► SERVICE Context

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.

Client App ──► Supabase Auth ──► JWT Token ──► Edge Function ──► User Context

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

  1. The protection middleware extracts the Authorization header
  2. The JWT is decoded (without calling Supabase Auth API) using resolveUserContextFromHeader()
  3. Custom claims (user_role, subscription_plan, subscription_active) are read from the token payload
  4. A UserContext is created with the resolved role and permissions
  5. 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:

  1. Dev bypass (local dev only): If DEV_BYPASS_AUTH=true and on localhost, create a context using DEV_USER_ID with configurable role
  2. JWT resolution: Decode Authorization: Bearer <token> and resolve role from custom claims
  3. Service auth fallback: If JWT resolution yields ANONYMOUS AND the request has a valid X-Service-Auth header, promote to SERVICE context
// 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 anon role 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 (.):

header.payload.signature

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 URL
  • sub (subject): User UUID
  • aud (audience): Usually "authenticated"
  • exp (expiration): Unix timestamp when token expires
  • iat (issued at): Unix timestamp when token was issued
  • email: User's email address
  • role: 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 or null
  • subscription_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:

  1. Visit https://jwt.io
  2. Paste your token
  3. 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:

deno task local:seed

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_expiry in supabase/config.toml
  • Expired tokens: Return 401 Unauthorized errors

Refresh Token Mechanism

Supabase provides refresh tokens to obtain new access tokens without re-authenticating:

const { data, error } = await supabase.auth.refreshSession();

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
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)            │
        │◄─────────────────────────────────────────────│
  1. User authenticates via Supabase Auth (OAuth, email/password)
  2. Supabase returns JWT access token with custom claims
  3. Client includes token in Authorization: Bearer <token> header
  4. Protection middleware decodes JWT, resolves role from custom claims
  5. 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)            │
        │◄──────────────────────────────────────────────│
  1. Service sends request with X-Service-Auth: <shared-secret> header
  2. JWT resolution finds no Bearer token → ANONYMOUS
  3. Service auth fallback validates X-Service-Auth → promotes to SERVICE
  4. Route handler executes with SERVICE context (read-only content access)

See Service-to-Service Guide for full details.

Token Validation Process

  1. Protection middleware extracts Authorization header
  2. JWT is decoded to extract custom claims (user_role, subscription_plan, subscription_active)
  3. Role is mapped: mapPlanToRole() converts plan names to UserRole enum
  4. Permissions are looked up from ROLE_PERMISSIONS[role]
  5. UserContext is stored in Hono context for route handlers

Error Handling at Each Level

Authentication errors (no valid JWT or service auth):

{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Authentication required"
  }
}

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:

  1. Decodes the JWT payload (base64)
  2. Extracts sub claim as user ID
  3. Extracts custom claims for role resolution
  4. Returns a UserContext with 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

  1. User JWT requests: The Authorization header is forwarded to Supabase, setting auth.uid() in the database context
  2. Service auth requests: No JWT → Supabase client uses anon key → RLS policies for anon role apply (published content readable, user data inaccessible)
  3. RLS policies filter results based on the database role (authenticated or anon)

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 Unauthorized with "code": "UNAUTHORIZED"
  • Request reaches protection middleware but has no valid auth

Causes:

  • No Authorization header and no X-Service-Auth header
  • JWT token is expired or malformed
  • Service auth secret is incorrect

Solutions:

  1. Add Authorization: Bearer <token> header (for user requests)
  2. Add X-Service-Auth: <secret> header (for service requests)
  3. Refresh or obtain a new JWT token
  4. Verify the SERVICE_AUTH_SECRET matches between services

"Insufficient permissions" (403)

Symptoms:

  • 403 Forbidden with "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 (lacks TRACK_PROGRESS)
  • Free user trying to access premium content

Solutions:

  1. Check the permission matrix above for the user's role
  2. For service auth, only access content endpoints (not /me/*)
  3. 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:

  1. Implement proactive token refresh (before expiration)
  2. Handle 401 errors by refreshing and retrying
  3. 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:

  1. Verify token was obtained from the correct environment
  2. Check JWT secret matches the target environment
  3. Tokens from local dev cannot be used in production (and vice versa)

Service Auth Not Working

Symptoms:

  • X-Service-Auth header present but still getting 401
  • Service requests treated as anonymous

Solutions:

  1. Verify SERVICE_AUTH_SECRET environment variable is set on edge functions
  2. Check the secret value matches exactly (no trailing whitespace)
  3. Ensure header name is exactly X-Service-Auth (case-sensitive)
  4. Confirm no Authorization header is also being sent (JWT takes precedence)

See Service-to-Service Guide for detailed troubleshooting.