Knowledge > Processes > Authentication Flows
Authentication Flows
The portfolio uses six distinct authentication patterns. Different properties use different strategies depending on their user model.
Pattern 1: Token-Based Admin Auth (CWA + PewSearch)
Used by church admins to access their dashboard at /admin/[token]. No passwords, no login forms -- just a unique URL.
How a token is created
WHEN a church completes Stripe Checkout:
1. The pre-checkout API creates a premium_churches row with an auto-generated UUID as admin_token
2. The webhook handler calls activateChurch()
3. activateChurch() sends a welcome email containing the magic link:
https://churchwiseai.com/admin/{admin_token}
(or https://pewsearch.com/admin/{admin_token} for PewSearch)
4. The admin clicks the link and lands directly in their dashboard
How a token is validated (resolveToken)
Both CWA and PewSearch implement resolveToken() in premium-queries.ts:
FUNCTION resolveToken(token):
Run TWO parallel queries:
Query A: SELECT * FROM premium_churches WHERE admin_token = token
Query B: SELECT * FROM church_team_members WHERE access_token = token AND is_active = true
IF Query A matches (admin token):
Load the associated church record via FK join
RETURN { premium, church, role: "admin", memberName: null }
IF Query B matches (team member token):
Update last_accessed_at on the team member (fire-and-forget)
Load the premium_churches and church records via premium_id FK
RETURN { premium, church, role: member.role, memberName: member.name }
IF neither matches:
RETURN null (the admin page shows a 404)
The two queries run in parallel for faster resolution -- the common case (admin token) resolves immediately without waiting for the team member lookup.
Token security properties
- Tokens are UUIDs (128-bit random) -- unguessable
- Tokens are not stored in cookies or localStorage by default (the URL IS the credential)
AdminTokenSetter(PewSearch) saves the token to sessionStorage asps_token_{slug}so the header CTA can show "My Dashboard" instead of "Claim Your Church"- Admin can rotate their token via
/api/premium/rotate-token(old link stops working) - Admin can request a new magic link via
/api/premium/resend-link
Pattern 2: Dual-Mode Auth (CWA session cookie + legacy token)
CWA has evolved from pure token-based auth to also support session cookies, with a compatibility layer that handles both.
How resolveTokenOrHeaders works
FUNCTION resolveTokenOrHeaders(tokenOrNull, headers):
1. IF a token is explicitly provided:
Try resolveToken(token)
IF it resolves: RETURN the result
(This ensures legacy /admin/[token] URLs always work,
even if the browser has a session cookie from a different church)
2. Check middleware-injected headers:
x-church-id: the church UUID
x-identity-id: the identity UUID
x-identity-role: the role string
3. IF churchId AND identityId are present (cookie-based auth):
Query premium_churches + churches by church_id
Use the role from x-identity-role header (default: "admin")
Look up identity name from church_identities table
RETURN { premium, church, role, memberName }
4. IF neither method resolves:
RETURN null
Usage in API routes
Every mutation endpoint (e.g., /api/premium/update) calls resolveTokenOrHeaders():
WHEN a settings update request arrives:
1. Verify CSRF token (origin validation)
2. Extract token from form data or JSON body
3. Call resolveTokenOrHeaders(token, request.headers)
4. IF null: RETURN 403 "Invalid token"
5. Check ROLE_SETTINGS[role] to verify the user can edit this section
6. Proceed with the update
Pattern 3: Team Member Access Tokens
Team members get their own unique tokens, separate from the admin token, with role-based permissions.
How team members are added
WHEN admin submits the "Add Team Member" form:
1. Validate: name and role are required, max 10 team members per church
2. INSERT into church_team_members:
- church_id, premium_id
- name, email, role (e.g., "prayer_team", "office_admin")
- access_token: auto-generated UUID (database default)
3. IF email was provided:
Send team invite email with the access link:
https://churchwiseai.com/admin/{access_token}
Role-based access control (RBAC)
Seven roles exist, each with different visibility:
ROLE | VISIBLE TABS | CAN EDIT
admin | overview, calls, requests, training, website, | Everything
| settings, status |
office_admin| overview, calls, requests, training, website, | basic, contact, website
| settings |
prayer_team | overview, requests | Nothing
care_team | overview, requests | Nothing
treasurer | overview | Nothing
volunteer_ | overview, requests | Nothing
coordinator| |
worship_ | overview, training | pastor_pulse only
leader | |
Confidential data (prayer text, callback reasons) is only visible to PASTORAL_ROLES (admin + office_admin). All other roles see redacted placeholders like "Confidential -- contact the pastor."
Pattern 4: Supabase Auth (ITW + SermonWise)
IllustrateTheWord and SermonWise use standard Supabase Auth (email/password + Google OAuth).
Sign-up and login flow
WHEN a user visits illustratetheword.com or sermonwise.ai:
1. The AuthProvider wraps the app in the root layout
2. It creates a browser-side Supabase client and listens for auth state changes
3. useAuth() exposes the current user to any client component
WHEN a user signs up:
1. User enters email + password (or clicks "Sign in with Google")
2. Supabase Auth creates the user and returns a session JWT
3. A trigger creates a corresponding row in the profiles table
4. The JWT is stored in cookies (httpOnly, managed by Supabase client)
WHEN a user logs in:
1. Supabase Auth validates credentials and returns a session JWT
2. Server components use the server-side Supabase client (reads cookies)
3. API routes use the admin Supabase client for elevated operations
Subscription gating
WHEN a user views an illustration detail page:
1. Server component checks auth state via server Supabase client
2. Look up the illustration's visibility_tier (public / free_signup / premium)
3. IF visibility_tier = "public": show full content
4. IF visibility_tier = "free_signup":
IF user is logged in: show full content
ELSE: show teaser + "Create free account" CTA
5. IF visibility_tier = "premium":
IF user has active subscription (user_subscriptions.status = "active" or "trialing"):
show full content
ELSE: show teaser + "Upgrade to Premium" CTA
Multiple identity providers
A single user can have both Google OAuth and email/password linked to their account. These are independent identity providers within Supabase Auth. A user who signed up with Google can later add a password, or vice versa.
Pattern 5: Founder Auth (FOUNDER_TOKEN)
Internal-only routes (e.g., /api/admin/provision-number, /founder/*) are gated by a static environment variable.
WHEN a founder-only API is called:
1. Read FOUNDER_TOKEN from environment variables
2. Check that the request's token parameter matches
(via query param for GET, or JSON body for POST)
3. IF no match: RETURN 401 "Unauthorized"
4. PROCEED with the operation
Note: This is a simple shared secret, not a session.
The token is a single static value that never rotates.
Used for operations like:
- Provisioning Twilio phone numbers for voice agent setup
- Viewing the founder dashboard
- Running system administration tasks
Pattern 6: CRON Auth (CRON_SECRET)
Vercel Cron jobs (e.g., daily audit) authenticate with a shared secret.
WHEN a cron endpoint is called:
1. Read CRON_SECRET from environment variables
2. Read the Authorization header from the request
3. IF header does not equal "Bearer {CRON_SECRET}":
RETURN 401 "Unauthorized"
4. PROCEED with the cron job
Note: Vercel automatically sends this header when invoking cron jobs
configured in vercel.json. Manual triggers must include it.
Pattern 7: Stripe Webhook Signature Verification
Stripe webhooks authenticate via HMAC signature, not tokens.
WHEN Stripe sends a webhook:
1. Stripe computes an HMAC-SHA256 of the raw request body using the endpoint's signing secret
2. Stripe sends the signature in the "stripe-signature" header
3. The handler calls stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET)
4. The Stripe SDK recomputes the HMAC and compares
5. IF mismatch: the request is rejected (400)
6. IF match: the event is trusted
This is not user authentication -- it verifies that the request genuinely came from Stripe and was not tampered with.
Cross-domain considerations
FA-008: Cross-property session sharing (not yet implemented)
Currently, a church admin who logs into CWA gets a session for churchwiseai.com only. If they navigate to pewsearch.com/admin, they need to use their PewSearch token separately. There is no single sign-on across properties yet.
FA-009: Subdomain cookies (not yet implemented)
PewSearch Pro Websites serve on subdomains ({slug}.pewsearch.com). The admin session cookie is scoped to pewsearch.com and does not extend to subdomains. A future enhancement could set the cookie domain to .pewsearch.com for subdomain coverage.
Summary: Auth pattern by property
| Property | Auth method | Credential store | Session type |
|---|---|---|---|
| ChurchWiseAI admin | Token + cookie (dual-mode) | premium_churches.admin_token, church_identities | URL token or httpOnly cookie |
| PewSearch admin | Token only | premium_churches.admin_token | URL (sessionStorage cache) |
| IllustrateTheWord | Supabase Auth | Supabase auth.users + profiles | JWT cookie |
| SermonWise | Supabase Auth | Supabase auth.users + profiles | JWT cookie |
| ShareWiseAI | Supabase Auth | Supabase auth.users + social_subscriptions | JWT cookie |
| Team members | Access token | church_team_members.access_token | URL |
| Founder routes | Static env var | FOUNDER_TOKEN env var | None (stateless) |
| Cron jobs | Static env var | CRON_SECRET env var | None (stateless) |
| Stripe webhooks | HMAC signature | STRIPE_WEBHOOK_SECRET env var | None (stateless) |