Knowledge > Products > ITW Premium > Auth Flow
ITW Authentication & Subscription Flow
Authentication Architecture
ITW uses Supabase Auth with three identity providers. Authentication is independent from PewSearch (token-based) and ChurchWiseAI (separate auth context). A user can have accounts on all three services -- they are not linked.
Supported Providers
| Provider | Method | Notes |
|---|---|---|
| Email/password | Standard signup with email confirmation | Min 8 character password |
| Google OAuth | One-click Google sign-in | Creates/links Supabase identity |
| Magic link (OTP) | Passwordless email login | Sends one-time link to email |
AuthProvider Context
The AuthProvider component (providers/AuthProvider.tsx) wraps the entire application and exposes auth state through React context:
interface AuthContext {
user: User | null // Supabase auth user object
session: Session | null // Current auth session (JWT)
loading: boolean // True during initial auth check
isPremium: boolean // True if active subscription exists
signOut: () => Promise<void>
}
Premium Status Check
The isPremium flag is derived from the user_subscriptions table, not from Supabase Auth metadata:
function checkPremiumStatus(userId) {
subscription = await supabase
.from('user_subscriptions')
.select('status')
.eq('user_id', userId)
.single()
return subscription?.status === 'active' || subscription?.status === 'trialing'
}
This check runs on every page load (via AuthProvider initialization) and is cached in React state for the session duration. The isPremium flag gates content access across all browse and detail pages.
Signup Flow
Route: /signup
The signup page collects account information and handles three auth methods:
┌──────────────────────────────────────────┐
│ Create Your Account │
│ │
│ [Google Sign In Button] │
│ ─── or ─── │
│ │
│ Full Name: [________________] │
│ Email: [________________] │
│ Password: [________________] │
│ (min 8 characters) │
│ │
│ [✓] Subscribe to newsletter │
│ │
│ [Cloudflare Turnstile CAPTCHA] │
│ │
│ [Create Account] │
│ │
│ Already have an account? [Sign In] │
└──────────────────────────────────────────┘
Signup Steps (Email/Password)
[1] User fills form: name, email, password
|
v
[2] Cloudflare Turnstile verification
| Uses CAPTCHA_SITE_KEY env var
| Blocks bots before hitting Supabase
|
v
[3] supabase.auth.signUp({ email, password, options: { data: { full_name } } })
| Creates user in Supabase Auth
| Triggers confirmation email
|
v
[4] Newsletter opt-in (if checked)
| Fire-and-forget POST to /api/newsletter
| Proxies to MailerLite via CWA API
| Failure does NOT block signup
|
v
[5] Confirmation email sent
| Contains magic link for email verification
|
v
[6] User clicks confirmation link
| Email verified, account active
|
v
[7] If ?plan=premium in URL:
| Redirect to /pricing?checkout=monthly
| Otherwise: redirect to homepage
|
v
[8] User is now signed in with free tier access
Signup Steps (Google OAuth)
[1] User clicks "Sign in with Google"
|
v
[2] supabase.auth.signInWithOAuth({ provider: 'google' })
| Redirects to Google consent screen
|
v
[3] Google returns auth code to Supabase callback
| Supabase creates/links user identity
| No email confirmation needed (Google-verified)
|
v
[4] User redirected back to ITW
| Fully authenticated, free tier access
Signup Steps (Magic Link)
[1] User enters email on signup page
|
v
[2] supabase.auth.signInWithOtp({ email })
| Sends one-time magic link to email
|
v
[3] User clicks magic link in email
| Supabase verifies OTP, creates session
|
v
[4] User redirected to ITW, fully authenticated
Newsletter Integration
The newsletter checkbox triggers a fire-and-forget POST:
async function subscribeToNewsletter(email, name) {
try {
await fetch('/api/newsletter', {
method: 'POST',
body: JSON.stringify({ email, name })
})
} catch {
// Silent failure -- newsletter is optional
// Never block signup for newsletter issues
}
}
The /api/newsletter route proxies to MailerLite through the ChurchWiseAI API (ITW does not have its own MailerLite API key). This is a convenience integration, not a critical path.
Signin Flow
Route: /signin
┌──────────────────────────────────────────┐
│ Welcome Back │
│ │
│ [Google Sign In Button] │
│ ─── or ─── │
│ │
│ Email: [________________] │
│ Password: [________________] │
│ │
│ [Sign In] │
│ │
│ [Send Magic Link Instead] │
│ │
│ Don't have an account? [Sign Up] │
└──────────────────────────────────────────┘
Three sign-in methods:
| Method | Function | Notes |
|---|---|---|
| Email/password | supabase.auth.signInWithPassword() | Standard password auth |
| Google OAuth | supabase.auth.signInWithOAuth() | Redirects to Google, returns session |
| Magic link | supabase.auth.signInWithOtp() | Sends OTP email, no password needed |
Stripe Checkout Flow
Premium subscriptions are purchased through Stripe Checkout, a hosted payment page. ITW never handles raw card numbers.
Route: POST /api/stripe/checkout
Checkout Steps
[1] User clicks "Subscribe" on /pricing page
| Sends POST with { billing: 'monthly' | 'annual' }
|
v
[2] Verify auth (401 if not signed in)
| user = supabase.auth.getUser(request)
| if (!user) return Response(401)
|
v
[3] Select price ID
| if (billing === 'annual')
| priceId = STRIPE_PREMIUM_ANNUAL_PRICE_ID
| else
| priceId = STRIPE_PREMIUM_MONTHLY_PRICE_ID
|
v
[4] Create or fetch Stripe customer
| Search by metadata: supabase_user_id = user.id
| If found: use existing customer
| If not: stripe.customers.create({
| email: user.email,
| metadata: { supabase_user_id: user.id }
| })
|
v
[5] Create Stripe Checkout session
| stripe.checkout.sessions.create({
| customer: customerId,
| line_items: [{ price: priceId, quantity: 1 }],
| mode: 'subscription',
| success_url: '/profile?subscription=success',
| cancel_url: '/pricing?subscription=cancelled',
| metadata: { supabase_user_id: user.id }
| })
|
v
[6] Return Stripe session URL
| return Response({ url: session.url })
|
v
[7] Client redirects to Stripe-hosted checkout page
| User enters payment details on Stripe's page
|
v
[8] Payment success:
| Stripe redirects to /profile?subscription=success
| Webhook fires asynchronously (see below)
|
v
[9] Payment cancelled:
Stripe redirects to /pricing?subscription=cancelled
User sees "Subscription cancelled" message with retry option
No Trial Period
Unlike ChurchWiseAI chat plans (14-day trial), ITW has no trial. Customers are charged immediately at checkout. The free tier (with ~68% of content) serves as the "try before you buy" mechanism.
Webhook Handler
Route: POST /api/stripe/webhook
The webhook endpoint processes Stripe subscription lifecycle events and keeps user_subscriptions in sync.
Handled Events
| Event | When It Fires | Action |
|---|---|---|
checkout.session.completed | Customer completes Stripe Checkout | Create/update user_subscriptions row: status='active' |
customer.subscription.updated | Plan change, payment method update, renewal | Update user_subscriptions: status, current_period_end |
customer.subscription.deleted | Subscription cancelled (end of period) | Update user_subscriptions: status='cancelled' |
customer.subscription.trial_will_end | Trial ending soon (not used -- no trials) | No action (defensive handler) |
invoice.payment_failed | Payment attempt failed | Update user_subscriptions: status='past_due', send email via Resend |
Webhook Processing Pseudocode
async function handleWebhook(request) {
// 1. Verify Stripe signature
body = await request.text()
signature = request.headers.get('stripe-signature')
event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET)
// 2. Extract user ID from event metadata
switch (event.type) {
case 'checkout.session.completed':
session = event.data.object
userId = session.metadata.supabase_user_id
customerId = session.customer
subscriptionId = session.subscription
// Fetch subscription details for period_end
subscription = await stripe.subscriptions.retrieve(subscriptionId)
await supabase.from('user_subscriptions').upsert({
user_id: userId,
stripe_customer_id: customerId,
stripe_subscription_id: subscriptionId,
status: 'active',
pricing_tier_id: getPricingTierId(subscription),
current_period_end: new Date(subscription.current_period_end * 1000),
updated_at: new Date()
}, { onConflict: 'user_id' })
break
case 'customer.subscription.updated':
subscription = event.data.object
userId = await getUserIdFromCustomer(subscription.customer)
await supabase.from('user_subscriptions').update({
status: mapStripeStatus(subscription.status),
current_period_end: new Date(subscription.current_period_end * 1000),
updated_at: new Date()
}).eq('user_id', userId)
break
case 'customer.subscription.deleted':
subscription = event.data.object
userId = await getUserIdFromCustomer(subscription.customer)
await supabase.from('user_subscriptions').update({
status: 'cancelled',
updated_at: new Date()
}).eq('user_id', userId)
break
case 'invoice.payment_failed':
invoice = event.data.object
userId = await getUserIdFromCustomer(invoice.customer)
await supabase.from('user_subscriptions').update({
status: 'past_due',
updated_at: new Date()
}).eq('user_id', userId)
// Send payment failure email via Resend
await sendPaymentFailureEmail(userId, invoice)
break
}
return Response(200)
}
User ID Resolution
Stripe events reference customers, not Supabase user IDs. Resolution path:
function getUserIdFromCustomer(stripeCustomerId) {
// Option 1: Customer metadata
customer = await stripe.customers.retrieve(stripeCustomerId)
if (customer.metadata.supabase_user_id)
return customer.metadata.supabase_user_id
// Option 2: user_subscriptions lookup
row = await supabase
.from('user_subscriptions')
.select('user_id')
.eq('stripe_customer_id', stripeCustomerId)
.single()
return row?.user_id
}
Rate Limiting
The webhook endpoint has rate limiting: 100 requests per 60 seconds per IP. This prevents replay attacks and accidental webhook floods from Stripe retries.
Transactional Emails
Payment failure triggers an email via Resend (not MailerLite -- Resend is used for transactional, MailerLite for marketing):
| Event | Content | |
|---|---|---|
invoice.payment_failed | Payment failure notice | "Your payment failed. Update your payment method to keep Premium access." |
user_subscriptions Schema
| Column | Type | Purpose |
|---|---|---|
id | UUID | Primary key |
user_id | UUID | FK to auth.users.id (Supabase Auth) |
stripe_customer_id | text | Stripe customer ID (cus_xxx) |
stripe_subscription_id | text | Stripe subscription ID (sub_xxx) |
status | text | 'active' | 'trialing' | 'past_due' | 'cancelled' | 'incomplete' |
pricing_tier_id | UUID | FK to pricing tier (monthly vs annual) |
current_period_end | timestamptz | When the current billing period ends |
created_at | timestamptz | Row creation timestamp |
updated_at | timestamptz | Last update timestamp |
Status State Machine
(no record) ──[checkout.session.completed]──> active
active ──[subscription.updated, payment ok]──> active (renewed)
active ──[invoice.payment_failed]──> past_due
past_due ──[payment succeeds]──> active
past_due ──[subscription.deleted]──> cancelled
active ──[subscription.deleted]──> cancelled
cancelled ──[new checkout]──> active (new subscription)
Profile Page
Route: /profile
The profile page shows account information and subscription management.
┌──────────────────────────────────────────┐
│ Your Profile │
│ │
│ Name: John Smith │
│ Email: john@example.com │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Subscription: [Active Badge] │ │
│ │ Plan: Premium ($9.95/mo) │ │
│ │ Next billing: April 15, 2026 │ │
│ │ │ │
│ │ [Manage Subscription] │ │
│ │ (opens Stripe billing portal) │ │
│ └─────────────────────────────────────┘ │
│ │
│ Favorites: 12 saved illustrations │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Danger Zone │ │
│ │ [Delete Account] │ │
│ │ Type "DELETE" to confirm │ │
│ └─────────────────────────────────────┘ │
└──────────────────────────────────────────┘
Subscription Badge Logic
function getSubscriptionBadge(subscription) {
if (!subscription) return { text: 'Free', color: 'gray' }
if (subscription.status === 'active') return { text: 'Active', color: 'green' }
if (subscription.status === 'past_due') return { text: 'Past Due', color: 'yellow' }
if (subscription.status === 'cancelled') return { text: 'Cancelled', color: 'red' }
return { text: 'Free', color: 'gray' }
}
Stripe Billing Portal
The "Manage Subscription" button opens the Stripe billing portal:
// POST /api/stripe/portal
async function createPortalSession(request) {
user = await getAuthUser(request)
subscription = await getSubscription(user.id)
session = await stripe.billingPortal.sessions.create({
customer: subscription.stripe_customer_id,
return_url: `${SITE_URL}/profile`
})
return Response({ url: session.url })
}
The portal allows customers to:
- View current subscription details
- Update payment method (card)
- Download invoices and receipts
- Cancel subscription (access continues until period end)
- Switch between monthly and annual billing
Account Deletion
Account deletion requires typing "DELETE" as confirmation:
async function deleteAccount(userId) {
// 1. Cancel Stripe subscription (if active)
subscription = await getSubscription(userId)
if (subscription?.stripe_subscription_id) {
await stripe.subscriptions.cancel(subscription.stripe_subscription_id)
}
// 2. Delete user_subscriptions row
await supabase.from('user_subscriptions').delete().eq('user_id', userId)
// 3. Delete Supabase Auth user (cascades to profile)
await supabase.auth.admin.deleteUser(userId)
// 4. Sign out locally
await supabase.auth.signOut()
}
Security Considerations
| Concern | Mitigation |
|---|---|
| Bot signups | Cloudflare Turnstile CAPTCHA on signup form |
| Webhook forgery | Stripe signature verification on every webhook call |
| Replay attacks | Webhook rate limiting (100 req/60s/IP) |
| Session hijacking | Supabase JWT with short expiry, refresh tokens |
| Premium bypass | Server-side visibility check (never trust client-side isPremium alone) |
| Account enumeration | Generic error messages ("Invalid credentials") |
| CAPTCHA env var | CAPTCHA_SITE_KEY (public, safe for client), secret key server-side only |
Environment Variables
| Variable | Purpose | Where |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | Supabase project URL | Client + Server |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Supabase anonymous key (RLS-gated) | Client + Server |
SUPABASE_SERVICE_ROLE_KEY | Supabase admin key (bypasses RLS) | Server only |
STRIPE_SECRET_KEY | Stripe API key | Server only |
STRIPE_WEBHOOK_SECRET | Webhook signature verification | Server only |
ITW_PREMIUM_MONTHLY | Monthly price ID | Server only |
ITW_PREMIUM_ANNUAL | Annual price ID | Server only |
CAPTCHA_SITE_KEY | Cloudflare Turnstile site key | Client |
CAPTCHA_SECRET_KEY | Cloudflare Turnstile secret | Server only |
RESEND_API_KEY | Transactional email (payment failure) | Server only |
Code Location
| File | Purpose |
|---|---|
sermon-illustrations/src/components/providers/AuthProvider.tsx | Auth context: user, session, isPremium, signOut |
sermon-illustrations/src/app/(auth)/signup/page.tsx | Signup form: email/password, Google OAuth, magic link |
sermon-illustrations/src/app/(auth)/signin/page.tsx | Signin form: three methods |
sermon-illustrations/src/app/profile/page.tsx | Profile page: subscription badge, manage, delete |
sermon-illustrations/src/app/api/stripe/checkout/route.ts | Stripe checkout session creation |
sermon-illustrations/src/app/api/stripe/webhook/route.ts | Stripe webhook handler (5 events) |
sermon-illustrations/src/app/api/stripe/portal/route.ts | Stripe billing portal session creation |
sermon-illustrations/src/app/api/newsletter/route.ts | MailerLite newsletter proxy |
sermon-illustrations/src/lib/queries/subscriptions.ts | Subscription status queries |
sermon-illustrations/src/lib/stripe.ts | Stripe client initialization, price ID constants |
See Also
- ITW Premium Overview -- product overview, pricing, visibility tiers
- Content Pipeline -- how content flows from generation to display
- Search & Browse -- browse paths, content gating in action
- Auth Flow Process -- cross-product auth patterns
- Checkout Flow Process -- Stripe checkout patterns
- Stripe Integration -- shared Stripe account, test/live modes