Service-to-Service Authentication Guide¶
How to authenticate backend services (Cloudflare Workers, SSR/ISR) with the Microlearning API.
Overview¶
The Microlearning API supports service-to-service (S2S) authentication for backend services that need to access content without a user session. This is designed for:
- Cloudflare Workers performing ISR (Incremental Static Regeneration)
- Next.js SSR pre-rendering content pages
- Build-time rendering of static content pages
- Content syndication services
Services authenticate via the X-Service-Auth header with a shared secret. No
user JWT token is required.
Architecture¶
┌──────────────────────┐ ┌──────────────────────┐ ┌───────────────┐
│ Cloudflare Worker │ │ Supabase Edge │ │ PostgreSQL │
│ (ISR / SSR) │──────────►│ Function │──────────►│ (RLS) │
│ │ │ │ │ │
│ Headers: │ │ Middleware Stack: │ │ anon role: │
│ X-Service-Auth: │ │ 1. Service Auth ✓ │ │ Published │
│ <secret> │ │ 2. Protection ✓ │ │ content │
│ │ │ → SERVICE role │ │ only │
│ (no Authorization) │ │ 3. Route handler │ │ │
└──────────────────────┘ └──────────────────────┘ └───────────────┘
Authentication Flow¶
Step-by-Step¶
- Service sends request with
X-Service-Auth: <shared-secret>header (noAuthorizationheader) - Service auth middleware (
functions/_shared/create-app.ts) validates the secret againstSERVICE_AUTH_SECRETenvironment variable - Protection middleware (
src/middleware/protection.ts) sees no JWT → resolvesANONYMOUScontext - Service auth fallback detects valid
X-Service-Auth→ promotes context toSERVICErole - Permission check passes —
SERVICErole has content read permissions - Route handler executes — Supabase client uses anon key (no JWT forwarded), RLS allows published content
Middleware Resolution Order¶
Request arrives
│
├─ 1. Dev bypass? (DEV_BYPASS_AUTH=true + localhost)
│ → Create ADMIN context
│
├─ 2. JWT present? (Authorization: Bearer <token>)
│ → Decode JWT, resolve role from claims
│
└─ 3. Service auth? (X-Service-Auth valid + no JWT)
→ Create SERVICE context
│
└─ None of the above → ANONYMOUS context
User JWT always takes precedence over service auth. If both headers are present, the JWT determines the user context.
Configuration¶
Edge Function Environment¶
Set SERVICE_AUTH_SECRET in your Supabase Edge Function environment:
# Local development (.env or functions/.env)
SERVICE_AUTH_SECRET=your-strong-random-secret-minimum-32-characters
# Production (Supabase Dashboard → Edge Functions → Secrets)
supabase secrets set SERVICE_AUTH_SECRET=your-strong-random-secret-here
Generate a strong secret:
Cloudflare Worker Environment¶
Set the same secret in your Cloudflare Worker:
# Set via Wrangler CLI (not in wrangler.toml — secrets must not be committed)
wrangler secret put SERVICE_AUTH_SECRET
# wrangler.toml — non-secret configuration only
[vars]
SUPABASE_FUNCTION_URL = "https://<project-ref>.supabase.co/functions/v1"
Local Development¶
For local development, you can skip service auth entirely:
Or provide the secret for testing the full flow:
Integration Examples¶
Cloudflare Worker (ISR)¶
interface Env {
SUPABASE_FUNCTION_URL: string;
SERVICE_AUTH_SECRET: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const slug = url.pathname.split("/").pop();
// Fetch spark content for pre-rendering
const response = await fetch(
`${env.SUPABASE_FUNCTION_URL}/content/sparks/${slug}`,
{
headers: {
"X-Service-Auth": env.SERVICE_AUTH_SECRET,
"Content-Type": "application/json",
},
// No Authorization header needed
},
);
if (!response.ok) {
return new Response("Content not found", { status: 404 });
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
},
});
},
};
Next.js ISR (getStaticProps)¶
// pages/sparks/[slug].tsx
export async function getStaticProps({ params }) {
const response = await fetch(
`${process.env.SUPABASE_FUNCTION_URL}/content/sparks/${params.slug}`,
{
headers: {
"X-Service-Auth": process.env.SERVICE_AUTH_SECRET,
},
},
);
if (!response.ok) {
return { notFound: true };
}
const { data } = await response.json();
return {
props: { spark: data },
revalidate: 3600, // Re-validate every hour
};
}
export async function getStaticPaths() {
const response = await fetch(
`${process.env.SUPABASE_FUNCTION_URL}/graph/sparks?per_page=100`,
{
headers: {
"X-Service-Auth": process.env.SERVICE_AUTH_SECRET,
},
},
);
const { data } = await response.json();
return {
paths: data.map((spark) => ({ params: { slug: spark.slug } })),
fallback: "blocking",
};
}
Next.js SSR (getServerSideProps)¶
// pages/domains/[slug].tsx
export async function getServerSideProps({ params }) {
const response = await fetch(
`${process.env.SUPABASE_FUNCTION_URL}/graph/domains/${params.slug}`,
{
headers: {
"X-Service-Auth": process.env.SERVICE_AUTH_SECRET,
},
},
);
if (!response.ok) {
return { notFound: true };
}
const { data } = await response.json();
return { props: { domain: data } };
}
Next.js App Router (Server Components)¶
// app/trails/[slug]/page.tsx
async function getTrail(slug: string) {
const response = await fetch(
`${process.env.SUPABASE_FUNCTION_URL}/graph/trails/${slug}`,
{
headers: {
"X-Service-Auth": process.env.SERVICE_AUTH_SECRET!,
},
next: { revalidate: 3600 },
},
);
if (!response.ok) return null;
const { data } = await response.json();
return data;
}
export default async function TrailPage({ params }) {
const trail = await getTrail(params.slug);
if (!trail) notFound();
return <TrailDetail trail={trail} />;
}
Accessible Endpoints¶
The SERVICE role can access all content endpoints but not user-specific endpoints.
Accessible (SERVICE role)¶
| Endpoint | Description |
|---|---|
GET /graph/domains |
List domains |
GET /graph/domains/{slug} |
Domain details |
GET /graph/trails |
List trails |
GET /graph/trails/{slug} |
Trail details |
GET /graph/concepts |
List concepts |
GET /graph/concepts/{slug} |
Concept details |
GET /graph/sparks |
List sparks (metadata) |
GET /graph/sparks/{slug} |
Spark metadata |
GET /graph/beacons |
List beacons |
GET /graph/beacons/{slug} |
Beacon details |
GET /graph/explore |
Faceted discovery |
GET /graph/path |
Learning path calculation |
GET /graph/breadcrumb/{type}/{slug} |
Navigation breadcrumbs |
GET /graph/links/sparks |
Spark prerequisite links |
GET /content/sparks/{slug} |
Lesson content (md/html) |
GET /content/sparks/{slug}/versions |
Content versions |
GET /home |
Homepage aggregation |
GET /journeys/* |
Journey catalog |
GET /search/* |
Full-text search |
GET /metadata/* |
Static enumerations |
GET /snapshots/* |
Content versioning |
Not Accessible (requires TRACK_PROGRESS permission)¶
| Endpoint | Reason |
|---|---|
GET /me |
User profile |
GET /me/stats |
User learning statistics |
GET /me/milestones |
User achievements |
GET /me/journeys |
User journey progress |
GET /me/events/* |
User learning events |
SERVICE Role Permissions¶
The SERVICE role (shared/src/protection/roles.ts) is granted:
| Permission | Description |
|---|---|
READ_PUBLIC_CONTENT |
Browse published content |
READ_PREVIEW_CONTENT |
Read preview/teaser content |
READ_FULL_CONTENT |
Read full lesson content |
READ_PREMIUM_CONTENT |
Read premium-tier content |
UNLIMITED_SEARCH |
Search without result limits |
BYPASS_RATE_LIMITS |
No rate limiting applied |
The SERVICE role does not have: TRACK_PROGRESS, CREATE_JOURNEY,
ACCESS_SPACED_REPETITION, ACCESS_ADVANCED_ANALYTICS, MANAGE_CONTENT,
MANAGE_USERS, or VIEW_ANALYTICS.
Database Access¶
When a service authenticates via X-Service-Auth (no JWT):
getSupabaseClient()creates a client with the anon key (no user auth header forwarded)- PostgreSQL RLS policies for the
anonrole apply - Published content is readable — RLS policies like
"Anyone can view published domains" USING (is_published = true)allow access - User-specific tables are inaccessible —
journeys,learning_events,milestonesrequireauth.uid() = user_idwhich returns null for anon - No RLS bypass — unlike using the
service_rolekey directly, this approach respects all RLS policies
Security Considerations¶
Secret Management¶
- Secret strength: Use a cryptographically random string of at least 32
characters (
openssl rand -base64 48) - Never commit secrets: Use environment variables or secret management tools, never hardcode in source
- Rotation: Plan for periodic rotation — update both the Cloudflare Worker and Supabase Edge Function secrets simultaneously
- Environment isolation: Use different secrets for staging and production
Access Boundaries¶
- No RLS bypass: Service auth does NOT bypass Row Level Security. The Supabase client uses the anon key, so only published content is accessible
- JWT precedence: If a request includes both
AuthorizationandX-Service-Auth, the JWT takes precedence. Service auth fallback only activates when JWT resolution yields ANONYMOUS - Read-only access: The SERVICE role only has read permissions. It cannot create, update, or delete content
- No user impersonation: Service auth creates a synthetic "service" user
context, not a real user session. The
userIdis set to the string"service"for audit trail purposes
Monitoring¶
- Audit trail: All service requests include
X-User-Role: serviceresponse header anduserId: "service"in audit logs - Request ID: Service requests are assigned
X-Request-IDheaders for correlation, just like user requests
Troubleshooting¶
403 "Invalid service authentication"¶
The X-Service-Auth header value does not match SERVICE_AUTH_SECRET.
Solutions:
- Verify the secret matches between your service and the edge function environment
- Check for whitespace or encoding issues in the secret value
- Ensure
SERVICE_AUTH_SECRETis set in the edge function environment - In local dev, check if
DEV_SKIP_SERVICE_AUTH=trueis set
401 "Authentication required"¶
The protection middleware could not resolve a non-ANONYMOUS user context.
Possible causes:
SERVICE_AUTH_SECRETis not configured in the edge function environment (service auth middleware skips the check, buthasValidServiceAuth()in the protection middleware returnstrue— this should work unless the service auth middleware rejected the request first)- The
X-Service-Authheader is missing from the request - The request is targeting a
/me/*route which requiresTRACK_PROGRESSpermission that the SERVICE role lacks
Solutions:
- Ensure
X-Service-Authheader is included in the request - Verify the endpoint is not user-specific (
/me/*) - Check edge function logs for
[service-auth]warnings
403 "Insufficient permissions"¶
The SERVICE role lacks a specific permission required by the route.
Solutions:
- Check the route's
requiredPermissionsin the protection middleware configuration - Review SERVICE role permissions in
shared/src/protection/roles.ts - This typically means the endpoint requires
TRACK_PROGRESS(user routes) — use a user JWT instead
Response Headers for Debugging¶
Every response includes headers useful for debugging:
| Header | Value (Service Auth) | Description |
|---|---|---|
X-User-Role |
service |
Resolved user role |
X-User-Id |
service. |
User ID (first 8 chars) |
X-Request-ID |
<uuid> |
Request correlation ID |
X-Function-Name |
graph, content etc |
Edge function that handled |
X-Auth-Status |
anonymous |
JWT auth status (no JWT) |
Related Documentation¶
- Authentication Guide — User authentication overview
- Auth Implementation — Technical implementation details
- API Protection Architecture — Full security model
- Secrets Management — Environment secrets setup