Skip to content

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

  1. Service sends request with X-Service-Auth: <shared-secret> header (no Authorization header)
  2. Service auth middleware (functions/_shared/create-app.ts) validates the secret against SERVICE_AUTH_SECRET environment variable
  3. Protection middleware (src/middleware/protection.ts) sees no JWT → resolves ANONYMOUS context
  4. Service auth fallback detects valid X-Service-Auth → promotes context to SERVICE role
  5. Permission check passes — SERVICE role has content read permissions
  6. 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:

openssl rand -base64 48

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:

# .env
DEV_SKIP_SERVICE_AUTH=true

Or provide the secret for testing the full flow:

# .env
SERVICE_AUTH_SECRET=local-dev-secret

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 anon role apply
  • Published content is readable — RLS policies like "Anyone can view published domains" USING (is_published = true) allow access
  • User-specific tables are inaccessiblejourneys, learning_events, milestones require auth.uid() = user_id which returns null for anon
  • No RLS bypass — unlike using the service_role key directly, this approach respects all RLS policies

Security Considerations

Secret Management

  1. Secret strength: Use a cryptographically random string of at least 32 characters (openssl rand -base64 48)
  2. Never commit secrets: Use environment variables or secret management tools, never hardcode in source
  3. Rotation: Plan for periodic rotation — update both the Cloudflare Worker and Supabase Edge Function secrets simultaneously
  4. Environment isolation: Use different secrets for staging and production

Access Boundaries

  1. No RLS bypass: Service auth does NOT bypass Row Level Security. The Supabase client uses the anon key, so only published content is accessible
  2. JWT precedence: If a request includes both Authorization and X-Service-Auth, the JWT takes precedence. Service auth fallback only activates when JWT resolution yields ANONYMOUS
  3. Read-only access: The SERVICE role only has read permissions. It cannot create, update, or delete content
  4. No user impersonation: Service auth creates a synthetic "service" user context, not a real user session. The userId is set to the string "service" for audit trail purposes

Monitoring

  1. Audit trail: All service requests include X-User-Role: service response header and userId: "service" in audit logs
  2. Request ID: Service requests are assigned X-Request-ID headers 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_SECRET is set in the edge function environment
  • In local dev, check if DEV_SKIP_SERVICE_AUTH=true is set

401 "Authentication required"

The protection middleware could not resolve a non-ANONYMOUS user context.

Possible causes:

  • SERVICE_AUTH_SECRET is not configured in the edge function environment (service auth middleware skips the check, but hasValidServiceAuth() in the protection middleware returns true — this should work unless the service auth middleware rejected the request first)
  • The X-Service-Auth header is missing from the request
  • The request is targeting a /me/* route which requires TRACK_PROGRESS permission that the SERVICE role lacks

Solutions:

  • Ensure X-Service-Auth header 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 requiredPermissions in 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)