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
| Layer | Technology |
|---|---|
| Frontend | React 18, TypeScript, Vite |
| Routing | React Router v7 |
| Auth backend | Supabase Auth |
| OAuth bridge | Supabase Edge Function (Deno) |
| Identity provider | ConsentKeys (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_.
| Variable | Description |
|---|---|
VITE_SUPABASE_URL | Supabase project URL |
VITE_SUPABASE_ANON_KEY | Supabase anon/public key |
VITE_CONSENT_KEYS_AUTHORIZE_URL | ConsentKeys authorize endpoint (e.g. https://.../auth or .../authorize) |
VITE_CONSENT_KEYS_CLIENT_ID | OAuth client ID |
VITE_CONSENT_KEYS_REDIRECT_URI | Full 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)
-
User clicks login
The login page callssignInWithProvider('consentkeys'), which builds the ConsentKeys authorize URL withclient_id,redirect_uri,response_type=code, andscope=openid email profile, then setswindow.location.hrefto that URL. -
ConsentKeys
User signs in at ConsentKeys. ConsentKeys redirects back to the Edge Function URL with an authorizationcodein the query string. -
Edge Function
The function (e.g.consentkeys-callback):- Exchanges the
codefor 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
redirectToset to the app's/auth/callbackURL - Returns a 302 redirect to that magic link
- Exchanges the
-
Supabase magic link
The browser follows the magic link. Supabase sets the session and redirects to theredirectToURL withaccess_tokenandrefresh_tokenin the hash (e.g..../auth/callback#access_token=...&refresh_token=...). -
Auth callback page
TheAuthCallbackcomponent reads the hash, extractsaccess_tokenandrefresh_token, callssupabase.auth.setSession(), then redirects to/dashboard. -
Dashboard
The dashboard route is wrapped inProtectedRoute. 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/dashboardif 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 toonAuthStateChange. - ConsentKeys:
signInWithProvider('consentkeys')builds the authorize URL fromconfig/envand redirects the browser. No Supabase OAuth call. - Other providers: Uses
supabase.auth.signInWithOAuth()withredirectTo: 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
useris already set (and not loading), redirects to/dashboard. - Button is disabled while
loadingor whenuseris present.
Protected route (src/components/protected-route.tsx)
- Uses
useAuth(). Whileloading, 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, parsesaccess_tokenandrefresh_token. - If both exist: calls
supabase.auth.setSession({ access_token, refresh_token }), thennavigate('/dashboard', { replace: true }). Optionally cleans the hash withhistory.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()foruserandsignOut. - Reads
user.user_metadataanduser.app_metadatafor 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.identitieswith 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), setverify_jwt = falsefor 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_URLSUPABASE_SERVICE_ROLE_KEYCONSENT_KEYS_CLIENT_IDCONSENT_KEYS_CLIENT_SECRETCONSENT_KEYS_REDIRECT_URI(optional; defaults tohttps://<SUPABASE_URL>/functions/v1/consentkeys-callback)CONSENT_KEYS_OIDC_DISCOVERY_URL(optional; defaults tohttps://api.consentkeys.com/.well-known/openid-configuration)CONSENT_KEYS_ALLOWED_ORIGINS(optional; comma-separated list of allowed app origins forstate)APP_URL(fallback whenstateis missing or invalid, e.g.http://localhost:5173/auth/callbackfor local dev)
Troubleshooting
| Problem | What to check |
|---|---|
| 401 "Missing authorization header" | Edge Function must have verify_jwt = false and be redeployed. |
Redirect to /# instead of /auth/callback | Set 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 OAuth | Check Edge Function logs, token exchange, and that user create/find by email runs and that userinfo contains the expected fields. |
| CORS errors | Add CORS headers in the Edge Function; allow the frontend origin and the callback URL in ConsentKeys and Supabase URL settings. |
Quick reference
| Concern | Location |
|---|---|
| App shell & routing | src/main.tsx, src/App.tsx |
| Auth state & sign-in/sign-out | src/contexts/auth-context.tsx, src/hooks/use-auth.ts |
| Login UI | src/components/login-page.tsx |
| Post-login redirect & session | src/components/auth-callback.tsx |
| Protected routes | src/components/protected-route.tsx |
| Supabase client | src/utils/supabase.ts |
| ConsentKeys env | src/config/env.ts |
| Types | src/types/auth.ts |
| Dashboard | src/pages/dashboard.tsx |
| Path aliases | vite.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.