Skip to content

Secrets Management Guide

How secrets are managed across local development and remote environments.

Architecture

┌─────────────────────────────────────────────────────────────────────────┐
│                        SECRETS ARCHITECTURE                              │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  LOCAL DEVELOPMENT                 REMOTE ENVIRONMENTS (CI/CD Only)     │
│  ─────────────────                 ───────────────────────────────      │
│                                                                          │
│  .env.local (gitignored)           GitHub Secrets                       │
│    │                                 │                                   │
│    └─► Developer's machine           ├─► SUPABASE_ACCESS_TOKEN          │
│                                      ├─► SUPABASE_PROJECT_ID            │
│                                      ├─► SUPABASE_PUBLISHABLE_KEY       │
│                                      ├─► SERVICE_AUTH_SECRET_PRODUCTION │
│                                      └─► SERVICE_AUTH_SECRET_STAGING    │
│                                              │                           │
│                                              ▼                           │
│                                    GitHub Actions Workflows              │
│                                              │                           │
│                            ┌─────────────────┼─────────────────┐        │
│                            ▼                 ▼                 ▼        │
│                       Production         Staging          Preview       │
│                    (main branch)    (develop branch)   (pr-* branch)    │
│                            │                 │                 │        │
│                            └────────►  supabase secrets set ◄──┘        │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Key Principle

Remote secrets are managed exclusively through CI/CD.

Developers only need:

  • .env.local for local development
  • Access to push code (CI/CD handles the rest)

Local Development Setup

Quick Start

# 1. Copy template
cp .env.local.example .env.local

# 2. Edit if needed (defaults work for most cases)

# 3. Start Supabase
supabase start

.env.local Contents

# Supabase local (defaults work automatically)
SUPABASE_URL=http://127.0.0.1:54321
SUPABASE_PUBLISHABLE_KEY=<auto-provided-by-supabase-start>
SUPABASE_SECRET_KEY=<auto-provided-by-supabase-start>

# Application
SERVICE_AUTH_SECRET=local-dev-secret
ENVIRONMENT=local
LOG_LEVEL=debug

# OAuth (optional - for local social login testing)
GITHUB_CLIENT_ID=your-local-oauth-app-id
GITHUB_CLIENT_SECRET=your-local-oauth-app-secret

GitHub Secrets Setup

Shared Repository Secrets

Add in Repository → Settings → Secrets and variables → Actions:

Secret Purpose Source How to Get
SUPABASE_ACCESS_TOKEN CLI authentication From Supabase Dashboard → Account → Access Tokens
SUPABASE_PROJECT_ID Project identifier From Supabase Dashboard → Settings → General → Reference ID
SUPABASE_PUBLISHABLE_KEY API authentication From Supabase Dashboard → Settings → API → Project API keys

Note: All three secrets above come from your Supabase project dashboard.

Environment-Specific Secrets

Production Environment (Settings → Environments → production):

Secret Purpose Used By How to Generate
SERVICE_AUTH_SECRET_PRODUCTION Custom app secret for service-to-service auth
(Frontend ↔ Backend authentication)
main branch → Supabase main branch openssl rand -base64 32
You generate this, not from Supabase

Staging Environment (Settings → Environments → staging):

Secret Purpose Used By How to Generate
SERVICE_AUTH_SECRET_STAGING Custom app secret for service-to-service auth
(Frontend ↔ Backend authentication)
develop branch → Supabase develop branch
Also used as seed for PR preview secrets
openssl rand -base64 32
You generate this, not from Supabase

What is SERVICE_AUTH_SECRET?

SERVICE_AUTH_SECRET is a custom application secret (not from Supabase) used to authenticate requests between your frontend (Vercel/Next.js) and backend (Supabase Edge Functions):

Security Flow:

1. User → Vercel Edge (rate limiting, WAF protection)
2. Next.js adds header: X-Service-Auth: <SERVICE_AUTH_SECRET>
3. Edge Function validates: header matches SERVICE_AUTH_SECRET
4. Direct public requests without valid secret are rejected

Why this matters:

  • Prevents direct access to Supabase Edge Functions from public internet
  • Routes all traffic through Vercel's rate limiting infrastructure
  • Ensures only your authorized frontend can call your backend APIs

Important: The same secret must be set in:

  • GitHub Environment Secret → used by CI/CD to set Supabase branch secret
  • Vercel Environment Variable → used by Next.js to add authentication header
  • Must match on both sides for requests to succeed

Security Benefits of Environment Secrets

  • Isolation: Staging workflows can't accidentally access production secrets
  • Approval: Production environment can require reviewers before deployment
  • Audit Trail: GitHub logs which environment each secret was accessed from
  • Risk Reduction: Compromised staging secret doesn't affect production

Branch-to-Secret Mapping

Persistent Branches

Git Branch Supabase Branch GitHub Environment SERVICE_AUTH_SECRET Source
main main (production) production SERVICE_AUTH_SECRET_PRODUCTION
develop develop (staging) staging SERVICE_AUTH_SECRET_STAGING

Ephemeral Branches (PR Previews)

Git Branch Supabase Branch GitHub Environment SERVICE_AUTH_SECRET Source
feat/*, fix/*, etc. pr-{number} (preview) preview-pr-{number} Auto-generated per PR

How PR secrets are generated:

# Unique secret per PR = SHA256(branch_name + staging_secret)
PREVIEW_SECRET=$(echo -n "pr-123-${SERVICE_AUTH_SECRET_STAGING}" | sha256sum)

This ensures:

  • Each PR preview has a unique, isolated secret
  • Secrets are deterministic (same PR always gets same secret)
  • Staging secret compromise doesn't expose all PR secrets
  • PR secrets are automatically deleted when branch is deleted

Secret Generation

# Generate secure random secrets
openssl rand -base64 32

# Or use Deno task
deno task secrets:generate

How GitHub Actions Finds Secrets

When a workflow job runs:

  1. If job has environment specified: Look for secrets in that environment first
  2. If secret not found in environment: Look in repository secrets (fallback)
  3. If not found: Workflow fails with "secret not found" error

Example:

  • deploy-production.yml job has environment: production
  • When it references ${{ secrets.SERVICE_AUTH_SECRET_PRODUCTION }}, GitHub first looks in the production environment secrets
  • Shared secrets like ${{ secrets.SUPABASE_ACCESS_TOKEN }} fall back to repository secrets

This allows environment-specific protection while sharing credentials across environments.


How CI/CD Sets Secrets

Production Deployment

When code is pushed to main, deploy-production.yml:

  1. Links to Supabase project
  2. Sets production secrets:
    supabase secrets set \
      ENVIRONMENT=production \
      LOG_LEVEL=info \
      SERVICE_AUTH_SECRET="${SERVICE_AUTH_SECRET_PRODUCTION}"
    
  3. Deploys Edge Functions
  4. Runs health checks

Staging Deployment

When code is pushed to develop, deploy-staging.yml:

  1. Links to Supabase project
  2. Sets staging secrets on develop branch:
    supabase secrets set --branch develop \
      ENVIRONMENT=staging \
      LOG_LEVEL=debug \
      SERVICE_AUTH_SECRET="${SERVICE_AUTH_SECRET_STAGING}"
    
  3. Deploys to staging branch
  4. Runs health checks

Preview Deployment

When a PR has the deploy-preview label, pr-checks.yml:

  1. Creates ephemeral branch pr-{number}
  2. Sets preview secrets:
    # Generates unique secret by hashing branch name + staging secret
    PREVIEW_SECRET=$(echo -n "pr-123-${SERVICE_AUTH_SECRET_STAGING}" | sha256sum | cut -c1-64)
    supabase secrets set --branch pr-123 \
      ENVIRONMENT=preview \
      LOG_LEVEL=debug \
      SERVICE_AUTH_SECRET="${PREVIEW_SECRET}"
    
  3. Deploys to preview branch
  4. Comments preview URL on PR

Complete Secret Flow Reference

Branch Type Git Branch Supabase Branch GitHub Env GitHub Secret Used Supabase Secret Set
Production main main production SERVICE_AUTH_SECRET_PRODUCTION SERVICE_AUTH_SECRET=xxx
Staging develop develop staging SERVICE_AUTH_SECRET_STAGING SERVICE_AUTH_SECRET=yyy
PR Preview feat/* pr-{N} preview-pr-{N} SERVICE_AUTH_SECRET_STAGING (seed) SERVICE_AUTH_SECRET=zzz (auto-generated)

Key Points:

  • Production and staging have dedicated, manually-set secrets in GitHub Environments
  • PR previews auto-generate unique secrets derived from staging secret
  • All secrets are set as Supabase branch secrets (via supabase secrets set --branch)
  • Ephemeral PR branches and their secrets are automatically deleted when PR closes

Secret Types

Project-Level GitHub Secrets (Used by CI/CD)

Secret Set In Used By
SUPABASE_ACCESS_TOKEN GitHub Secrets CLI operations (deploy, migrations)
SUPABASE_PROJECT_ID GitHub Secrets Project identification
SUPABASE_PUBLISHABLE_KEY GitHub Secrets API calls (smoke tests, health checks)

Note: SUPABASE_SECRET_KEY (Supabase's service role key) is currently NOT used:

Current Architecture:

  • ✅ All Edge Function operations use SUPABASE_PUBLISHABLE_KEY + user authentication
  • ✅ Row Level Security (RLS) is enforced on all database operations
  • ✅ No admin/privileged operations at runtime

When to add it: If you need Edge Functions to perform admin operations (create users, bypass RLS, etc.):

  1. Add to GitHub Secrets (repository level):

    SUPABASE_SECRET_KEY = <from Dashboard → Settings → API → service_role key>
    

  2. Update workflows to set it as Supabase branch secret:

    supabase secrets set --branch $BRANCH \
    SUPABASE_SECRET_KEY="${{ secrets.SUPABASE_SECRET_KEY }}"
    

  3. Use in code:

    import { getSupabaseAdmin } from "@core/index.ts";
    const admin = getSupabaseAdmin(c); // Bypasses RLS
    

Currently used in:

  • Local scripts only (.env.local): seed-curriculum.ts, analyze-state.ts, verify-snapshot.ts
  • NOT used in production Edge Functions

Branch-Level (Different per Environment)

Secret Production Staging Preview
ENVIRONMENT production staging preview
LOG_LEVEL info debug debug
SERVICE_AUTH_SECRET Unique Unique Auto-generated

Security Best Practices

Do ✅

  • Store remote secrets only in GitHub Secrets
  • Use unique SERVICE_AUTH_SECRET per environment
  • Generate secrets with openssl rand -base64 32 (minimum 32 chars)
  • Rotate secrets quarterly
  • Use GitHub environment protection for production

Don't ❌

  • Store production secrets locally (.env.production files)
  • Share secrets via Slack, email, or PR comments
  • Reuse the same secret across environments
  • Commit any .env files (check .gitignore)

Rotating Secrets

When to Rotate

  • Quarterly (recommended schedule)
  • When a team member leaves
  • If a secret may have been exposed

How to Rotate

  1. Generate new secret:

    openssl rand -base64 32
    

  2. Update GitHub Secrets:

  3. Repository → Settings → Secrets → Update secret

  4. Trigger redeployment:

    # Push an empty commit to trigger deployment
    git commit --allow-empty -m "chore: rotate secrets"
    git push origin main      # For production
    git push origin develop   # For staging
    

  5. Update dependent services:

  6. Frontend (Vercel): Update SERVICE_AUTH_SECRET
  7. Any other services using this secret

Verifying Secrets

Check Supabase Secrets

# Link project first
supabase link --project-ref <project-id>

# List production secrets
supabase secrets list

# List staging secrets
supabase secrets list --branch develop

# List preview secrets
supabase secrets list --branch pr-123

Verify in CI/CD

Check GitHub Actions workflow logs for secret-setting steps.


Troubleshooting

"Missing required secret" in CI

  1. Go to Repository → Settings → Secrets
  2. Verify secret name matches exactly (case-sensitive)
  3. Ensure secret value is not empty

"Invalid SERVICE_AUTH_SECRET" at runtime

  1. Check secrets are set on correct branch:
    supabase secrets list --branch <branch>
    
  2. Verify secret matches between Supabase and frontend
  3. Redeploy to refresh secrets

Local auth fails

  1. Verify .env.local exists and has values
  2. Check Supabase is running: supabase status
  3. For OAuth: verify you have separate localhost OAuth apps

Secrets not updating after change

  1. GitHub Actions caches may need clearing
  2. Trigger a fresh deployment
  3. For Supabase: secrets apply on next function invocation

Commands Reference

# Generate secure secret
openssl rand -base64 32
deno task secrets:generate

# List current Supabase secrets
supabase secrets list
supabase secrets list --branch develop
supabase secrets list --branch pr-123

# Manual secret set (emergency only - prefer CI/CD)
supabase secrets set KEY=value
supabase secrets set --branch develop KEY=value