Skip to main content

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 as ps_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

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

PropertyAuth methodCredential storeSession type
ChurchWiseAI adminToken + cookie (dual-mode)premium_churches.admin_token, church_identitiesURL token or httpOnly cookie
PewSearch adminToken onlypremium_churches.admin_tokenURL (sessionStorage cache)
IllustrateTheWordSupabase AuthSupabase auth.users + profilesJWT cookie
SermonWiseSupabase AuthSupabase auth.users + profilesJWT cookie
ShareWiseAISupabase AuthSupabase auth.users + social_subscriptionsJWT cookie
Team membersAccess tokenchurch_team_members.access_tokenURL
Founder routesStatic env varFOUNDER_TOKEN env varNone (stateless)
Cron jobsStatic env varCRON_SECRET env varNone (stateless)
Stripe webhooksHMAC signatureSTRIPE_WEBHOOK_SECRET env varNone (stateless)