Skip to main content

Authentication & Setup Guide

This guide documents how authentication is implemented in the React + Supabase starter: ConsentKeys (OIDC) via a Supabase Edge Function, with protected routes and a user dashboard.

Overview

LayerTechnology
FrontendReact 18, TypeScript, Vite
RoutingReact Router v7
Auth backendSupabase Auth
OAuth bridgeSupabase Edge Function (Deno)
Identity providerConsentKeys (custom OIDC)

The app does not use Supabase's built-in OAuth providers for ConsentKeys. Instead, the frontend redirects to ConsentKeys, and a custom Edge Function receives the callback, exchanges the code, creates or finds the user in Supabase, then redirects the browser through a Supabase magic link so the client gets a session.


Project structure

src/
├── main.tsx # Entry: BrowserRouter, AuthProvider, App
├── App.tsx # Routes: /, /auth/callback, /dashboard (protected)
├── index.css # Global styles
├── vite-env.d.ts # Vite/import.meta types
├── config/
│ └── env.ts # ConsentKeys env (authorize URL, client id, redirect URI)
├── contexts/
│ ├── auth-context.ts # Auth context definition
│ └── auth-context.tsx # AuthProvider: session, signIn, signOut
├── hooks/
│ ├── index.ts # Re-exports hooks
│ └── use-auth.ts # useAuth() from AuthContext
├── components/
│ ├── login-page.tsx # Login UI; redirects if already signed in
│ ├── auth-callback.tsx # Handles magic-link hash, setSession, redirect to dashboard
│ └── protected-route.tsx # Wraps protected routes; redirects to / if no user
├── pages/
│ ├── dashboard.tsx # Post-login dashboard (user + identities)
│ └── dashboard.css
├── types/
│ └── auth.ts # OAuthProvider, AuthContextValue
└── utils/
└── supabase.ts # Supabase client (VITE_SUPABASE_*)

Tech stack & scripts

  • Dependencies: @supabase/supabase-js, react, react-dom, react-router-dom
  • Dev: Vite, TypeScript, ESLint
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
}

Path aliases (in vite.config.ts and tsconfig) include: @/, @components, @contexts, @hooks, @pages, @types, @utils.


Environment variables

Create .env or .env.local from env.example. All client-side variables must be prefixed with VITE_.

VariableDescription
VITE_SUPABASE_URLSupabase project URL
VITE_SUPABASE_ANON_KEYSupabase anon/public key
VITE_CONSENT_KEYS_AUTHORIZE_URLConsentKeys authorize endpoint (e.g. https://.../auth or .../authorize)
VITE_CONSENT_KEYS_CLIENT_IDOAuth client ID
VITE_CONSENT_KEYS_REDIRECT_URIFull URL of the Edge Function callback (e.g. https://<project>.supabase.co/functions/v1/consentkeys-callback)

Example env.example:

VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
VITE_CONSENT_KEYS_AUTHORIZE_URL=https://api.consentkeys.com/auth
VITE_CONSENT_KEYS_CLIENT_ID=ck_your_client_id
VITE_CONSENT_KEYS_REDIRECT_URI=https://your-project.supabase.co/functions/v1/consentkeys-callback

Authentication flow (step-by-step)

  1. User clicks login
    The login page calls signInWithProvider('consentkeys'), which builds the ConsentKeys authorize URL with client_id, redirect_uri, response_type=code, and scope=openid email profile, then sets window.location.href to that URL.

  2. ConsentKeys
    User signs in at ConsentKeys. ConsentKeys redirects back to the Edge Function URL with an authorization code in the query string.

  3. Edge Function
    The function (e.g. consentkeys-callback):

    • Exchanges the code for tokens at ConsentKeys token endpoint
    • Fetches user info from ConsentKeys userinfo endpoint
    • Creates or finds a user in Supabase (e.g. by email) using the Admin API
    • Generates a Supabase magic link with redirectTo set to the app's /auth/callback URL
    • Returns a 302 redirect to that magic link
  4. Supabase magic link
    The browser follows the magic link. Supabase sets the session and redirects to the redirectTo URL with access_token and refresh_token in the hash (e.g. .../auth/callback#access_token=...&refresh_token=...).

  5. Auth callback page
    The AuthCallback component reads the hash, extracts access_token and refresh_token, calls supabase.auth.setSession(), then redirects to /dashboard.

  6. Dashboard
    The dashboard route is wrapped in ProtectedRoute. If there is a session, the dashboard renders; otherwise the user is redirected to /.


Application entry and routing

Entry point (src/main.tsx)

The app is wrapped in BrowserRouter and AuthProvider so auth state is available everywhere:

createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
)

Routes (src/App.tsx)

  • / → Login page (redirects to /dashboard if already authenticated).
  • /auth/callback → Handles the magic-link redirect and sets the session.
  • /dashboard → Protected; only rendered when the user is authenticated.
<Routes>
<Route path="/" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
</Route>
</Routes>

Auth context and hook

Context definition (src/contexts/auth-context.ts)

The context holds the auth value type (user, session, loading, signIn, signOut):

import { createContext } from 'react'
import type { AuthContextValue } from '@/types/auth'

export const AuthContext = createContext<AuthContextValue | undefined>(undefined)

Auth types (src/types/auth.ts)

import type { Session, User } from '@supabase/supabase-js'

export type OAuthProvider = 'google' | 'github' | 'azure' | 'consentkeys'

export type AuthContextValue = {
user: User | null
session: Session | null
loading: boolean
signInWithProvider: (provider: OAuthProvider) => Promise<void>
signOut: () => Promise<void>
}

Auth provider (src/contexts/auth-context.tsx)

  • On mount: fetches initial session with supabase.auth.getSession() and subscribes to onAuthStateChange.
  • ConsentKeys: signInWithProvider('consentkeys') builds the authorize URL from config/env and redirects the browser. No Supabase OAuth call.
  • Other providers: Uses supabase.auth.signInWithOAuth() with redirectTo: origin + '/auth/callback'.
  • Sign out: supabase.auth.signOut().

ConsentKeys config is centralized in src/config/env.ts, which reads VITE_CONSENT_KEYS_* and exposes consentKeysEnv (e.g. authorizeUrl, clientId, redirectUri).

useAuth hook (src/hooks/use-auth.ts)

import { useContext } from 'react'
import { AuthContext } from '@contexts/auth-context'

export function useAuth() {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth must be used within an AuthProvider')
return context
}

Use useAuth() in any component under AuthProvider to access user, session, loading, signInWithProvider, and signOut.


Components

Login page (src/components/login-page.tsx)

  • Renders a "Continue with ConsentKeys" button that calls signInWithProvider('consentkeys').
  • If user is already set (and not loading), redirects to /dashboard.
  • Button is disabled while loading or when user is present.

Protected route (src/components/protected-route.tsx)

  • Uses useAuth(). While loading, shows a loading message.
  • If !user, redirects to / with <Navigate to="/" replace />.
  • Otherwise renders <Outlet /> so child routes (e.g. /dashboard) render.

Auth callback (src/components/auth-callback.tsx)

  • On mount: reads window.location.hash, parses access_token and refresh_token.
  • If both exist: calls supabase.auth.setSession({ access_token, refresh_token }), then navigate('/dashboard', { replace: true }). Optionally cleans the hash with history.replaceState.
  • If tokens are missing: shows an error and a "Back to sign in" button (no PKCE/code exchange here; the flow is magic-link only for this provider).

Supabase client and config

Client (src/utils/supabase.ts)

import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl ?? '', supabaseAnonKey ?? '')

ConsentKeys env (src/config/env.ts)

Centralizes ConsentKeys-related env so the auth context stays clean:

const {
VITE_CONSENT_KEYS_AUTHORIZE_URL: consentAuthorizeUrl,
VITE_CONSENT_KEYS_CLIENT_ID: consentClientId,
VITE_CONSENT_KEYS_REDIRECT_URI: consentRedirectUri,
} = import.meta.env

export const consentKeysEnv = {
authorizeUrl: consentAuthorizeUrl,
clientId: consentClientId,
redirectUri: consentRedirectUri,
}

Dashboard

The dashboard (src/pages/dashboard.tsx) is a protected page that:

  • Uses useAuth() for user and signOut.
  • Reads user.user_metadata and user.app_metadata for display.
  • Shows:
    • Welcome header (e.g. full name, email)
    • Account panel: provider, username, email verified, created/last sign-in
    • Metadata panel: user id, provider id, aud, role, updated
    • Identities: list of user.identities with provider, email, identity id, created

Dates are formatted with a small formatDate() helper. Styling lives in dashboard.css.


Edge Function (ConsentKeys callback)

The Edge Function runs in Supabase (Deno) and is the OAuth callback for ConsentKeys. It uses OIDC discovery to resolve token and userinfo endpoints and state (when provided by the frontend as the app origin) to redirect the magic link back to the correct app URL. If state is missing or not in the allowlist, it falls back to APP_URL.

Configuration

  • Path: e.g. supabase/functions/consentkeys-callback/
  • Config: In the function's config.toml (or project config), set verify_jwt = false for this function so the ConsentKeys redirect (which has no Supabase JWT) is accepted.

Example:

[functions.consentkeys-callback]
verify_jwt = false

Frontend: pass app origin as state

When building the ConsentKeys authorize URL, pass your app origin as the state parameter (e.g. state=encodeURIComponent(window.location.origin)). The Edge Function decodes state and uses it as the base for the magic link redirect ({origin}/auth/callback). Optionally restrict allowed origins with CONSENT_KEYS_ALLOWED_ORIGINS.

Edge Function code

// supabase/functions/consentkeys-callback/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'

// OIDC Discovery URL - can be set via env or defaults to ConsentKeys
const OIDC_DISCOVERY_URL = Deno.env.get('CONSENT_KEYS_OIDC_DISCOVERY_URL') ||
'https://api.consentkeys.com/.well-known/openid-configuration'

const CLIENT_ID = Deno.env.get('CONSENT_KEYS_CLIENT_ID')!
const CLIENT_SECRET = Deno.env.get('CONSENT_KEYS_CLIENT_SECRET')!
// Fallback when state is missing or invalid (e.g. production default)
const APP_URL_FALLBACK = Deno.env.get('APP_URL') ?? 'http://localhost:5173'
// Redirect URI for token exchange - must match exactly what's registered in ConsentKeys
const REDIRECT_URI = Deno.env.get('CONSENT_KEYS_REDIRECT_URI') ||
`https://${(Deno.env.get('SUPABASE_URL') ?? '').replace('https://', '')}/functions/v1/consentkeys-callback`

// Optional: comma-separated list of allowed origins for magic link redirect (e.g. "http://localhost:5173,https://urge-tracker.com")
// If set, state must be in this list. If not set, any origin is allowed (use allowlist in production).
const ALLOWED_ORIGINS = Deno.env.get('CONSENT_KEYS_ALLOWED_ORIGINS')?.split(',').map((o) => o.trim()).filter(Boolean) ?? null

// Cache for OIDC configuration (fetched once per function instance)
let oidcConfig: {
token_endpoint: string
userinfo_endpoint: string
} | null = null

/**
* Resolve the app URL for the magic link redirect.
* Uses state (app origin from frontend) when present and allowed; otherwise falls back to APP_URL.
*/
function getAppRedirectUrl(stateParam: string | null): string {
if (!stateParam || stateParam === '') return APP_URL_FALLBACK
let origin: string
try { origin = decodeURIComponent(stateParam) } catch { return APP_URL_FALLBACK }
try {
const u = new URL(origin)
const hasPath = u.pathname !== '' && u.pathname !== '/'
if (hasPath || u.search || u.hash) return APP_URL_FALLBACK
origin = u.origin
} catch { return APP_URL_FALLBACK }
if (ALLOWED_ORIGINS && ALLOWED_ORIGINS.length > 0) {
if (!ALLOWED_ORIGINS.some((o) => o === origin)) return APP_URL_FALLBACK
}
return `${origin}/auth/callback`
}

async function getOidcConfig() {
if (oidcConfig) return oidcConfig
const response = await fetch(OIDC_DISCOVERY_URL)
if (!response.ok) throw new Error(`Failed to fetch OIDC config: ${response.status}`)
const config = await response.json()
if (!config.token_endpoint || !config.userinfo_endpoint) throw new Error('OIDC configuration missing required endpoints')
oidcConfig = { token_endpoint: config.token_endpoint, userinfo_endpoint: config.userinfo_endpoint }
return oidcConfig
}

const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}

serve(async (req) => {
if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders })
try {
const url = new URL(req.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
if (!code) return new Response('Missing code', { status: 400, headers: corsHeaders })

const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
const oidc = await getOidcConfig()
const redirectTo = getAppRedirectUrl(state)

const tokenRes = await fetch(oidc.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
}),
})
const tokenData = await tokenRes.json()
if (!tokenRes.ok) return new Response(`Token exchange failed: ${JSON.stringify(tokenData)}`, { status: tokenRes.status, headers: corsHeaders })

const userRes = await fetch(oidc.userinfo_endpoint, {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
})
const providerUser = await userRes.json()
if (!userRes.ok) return new Response('Userinfo failed', { status: 500, headers: corsHeaders })
const email = providerUser.email
if (!email) return new Response('Email required', { status: 400, headers: corsHeaders })

const { data: existingUsers } = await supabaseAdmin.auth.admin.listUsers()
const existing = existingUsers?.users.find((u) => u.email === email)
let user = existing
if (!existing) {
const { data: userData, error: userError } = await supabaseAdmin.auth.admin.createUser({
email,
email_confirm: true,
user_metadata: {
provider: 'consentkeys',
provider_id: providerUser.sub,
full_name: providerUser.name,
username: email.split('@')[0] || `user_${providerUser.sub}`,
},
})
if (userError) return new Response(`Create user failed: ${userError.message}`, { status: 500, headers: corsHeaders })
user = userData.user
}

const { data: linkData, error: linkError } = await supabaseAdmin.auth.admin.generateLink({
type: 'magiclink',
email,
options: { redirectTo },
})
if (linkError || !linkData?.properties?.action_link) {
return new Response(`Magic link failed: ${linkError?.message ?? 'No action link'}`, { status: 500, headers: corsHeaders })
}
return new Response(null, { status: 302, headers: { Location: linkData.properties.action_link, ...corsHeaders } })
} catch (err) {
console.error('Unexpected error:', err)
return new Response(`Error: ${err}`, { status: 500, headers: corsHeaders })
}
})

Secrets (Supabase Edge Function secrets)

  • SUPABASE_URL
  • SUPABASE_SERVICE_ROLE_KEY
  • CONSENT_KEYS_CLIENT_ID
  • CONSENT_KEYS_CLIENT_SECRET
  • CONSENT_KEYS_REDIRECT_URI (optional; defaults to https://<SUPABASE_URL>/functions/v1/consentkeys-callback)
  • CONSENT_KEYS_OIDC_DISCOVERY_URL (optional; defaults to https://api.consentkeys.com/.well-known/openid-configuration)
  • CONSENT_KEYS_ALLOWED_ORIGINS (optional; comma-separated list of allowed app origins for state)
  • APP_URL (fallback when state is missing or invalid, e.g. http://localhost:5173/auth/callback for local dev)

Troubleshooting

ProblemWhat to check
401 "Missing authorization header"Edge Function must have verify_jwt = false and be redeployed.
Redirect to /# instead of /auth/callbackSet Edge Function secret APP_URL to the full callback URL (e.g. http://localhost:5173/auth/callback) and ensure the magic link's redirectTo uses it.
"both auth code and code verifier should be non-empty"Do not use exchangeCodeForSession() for this flow. The callback page must read access_token and refresh_token from the URL hash and call setSession().
User not found after OAuthCheck Edge Function logs, token exchange, and that user create/find by email runs and that userinfo contains the expected fields.
CORS errorsAdd CORS headers in the Edge Function; allow the frontend origin and the callback URL in ConsentKeys and Supabase URL settings.

Quick reference

ConcernLocation
App shell & routingsrc/main.tsx, src/App.tsx
Auth state & sign-in/sign-outsrc/contexts/auth-context.tsx, src/hooks/use-auth.ts
Login UIsrc/components/login-page.tsx
Post-login redirect & sessionsrc/components/auth-callback.tsx
Protected routessrc/components/protected-route.tsx
Supabase clientsrc/utils/supabase.ts
ConsentKeys envsrc/config/env.ts
Typessrc/types/auth.ts
Dashboardsrc/pages/dashboard.tsx
Path aliasesvite.config.ts, tsconfig.app.json

This document is intended to be used as a single Docusaurus page (e.g. under docs/) for "Authentication & Setup". Adjust sidebar_position and title in the frontmatter to fit your docs structure.