Skip to main content

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

ProviderMethodNotes
Email/passwordStandard signup with email confirmationMin 8 character password
Google OAuthOne-click Google sign-inCreates/links Supabase identity
Magic link (OTP)Passwordless email loginSends 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
[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:

MethodFunctionNotes
Email/passwordsupabase.auth.signInWithPassword()Standard password auth
Google OAuthsupabase.auth.signInWithOAuth()Redirects to Google, returns session
Magic linksupabase.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

EventWhen It FiresAction
checkout.session.completedCustomer completes Stripe CheckoutCreate/update user_subscriptions row: status='active'
customer.subscription.updatedPlan change, payment method update, renewalUpdate user_subscriptions: status, current_period_end
customer.subscription.deletedSubscription cancelled (end of period)Update user_subscriptions: status='cancelled'
customer.subscription.trial_will_endTrial ending soon (not used -- no trials)No action (defensive handler)
invoice.payment_failedPayment attempt failedUpdate 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):

EventEmailContent
invoice.payment_failedPayment failure notice"Your payment failed. Update your payment method to keep Premium access."

user_subscriptions Schema

ColumnTypePurpose
idUUIDPrimary key
user_idUUIDFK to auth.users.id (Supabase Auth)
stripe_customer_idtextStripe customer ID (cus_xxx)
stripe_subscription_idtextStripe subscription ID (sub_xxx)
statustext'active' | 'trialing' | 'past_due' | 'cancelled' | 'incomplete'
pricing_tier_idUUIDFK to pricing tier (monthly vs annual)
current_period_endtimestamptzWhen the current billing period ends
created_attimestamptzRow creation timestamp
updated_attimestamptzLast 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

ConcernMitigation
Bot signupsCloudflare Turnstile CAPTCHA on signup form
Webhook forgeryStripe signature verification on every webhook call
Replay attacksWebhook rate limiting (100 req/60s/IP)
Session hijackingSupabase JWT with short expiry, refresh tokens
Premium bypassServer-side visibility check (never trust client-side isPremium alone)
Account enumerationGeneric error messages ("Invalid credentials")
CAPTCHA env varCAPTCHA_SITE_KEY (public, safe for client), secret key server-side only

Environment Variables

VariablePurposeWhere
NEXT_PUBLIC_SUPABASE_URLSupabase project URLClient + Server
NEXT_PUBLIC_SUPABASE_ANON_KEYSupabase anonymous key (RLS-gated)Client + Server
SUPABASE_SERVICE_ROLE_KEYSupabase admin key (bypasses RLS)Server only
STRIPE_SECRET_KEYStripe API keyServer only
STRIPE_WEBHOOK_SECRETWebhook signature verificationServer only
ITW_PREMIUM_MONTHLYMonthly price IDServer only
ITW_PREMIUM_ANNUALAnnual price IDServer only
CAPTCHA_SITE_KEYCloudflare Turnstile site keyClient
CAPTCHA_SECRET_KEYCloudflare Turnstile secretServer only
RESEND_API_KEYTransactional email (payment failure)Server only

Code Location

FilePurpose
sermon-illustrations/src/components/providers/AuthProvider.tsxAuth context: user, session, isPremium, signOut
sermon-illustrations/src/app/(auth)/signup/page.tsxSignup form: email/password, Google OAuth, magic link
sermon-illustrations/src/app/(auth)/signin/page.tsxSignin form: three methods
sermon-illustrations/src/app/profile/page.tsxProfile page: subscription badge, manage, delete
sermon-illustrations/src/app/api/stripe/checkout/route.tsStripe checkout session creation
sermon-illustrations/src/app/api/stripe/webhook/route.tsStripe webhook handler (5 events)
sermon-illustrations/src/app/api/stripe/portal/route.tsStripe billing portal session creation
sermon-illustrations/src/app/api/newsletter/route.tsMailerLite newsletter proxy
sermon-illustrations/src/lib/queries/subscriptions.tsSubscription status queries
sermon-illustrations/src/lib/stripe.tsStripe client initialization, price ID constants

See Also