Skip to Content
WidgetAuth integrations

Auth integrations

This page shows you how to wire resolveUser inside createUnchurnHandler for four common auth systems: Clerk, NextAuth / Auth.js, Supabase Auth, and a custom header-based approach.

resolveUser is your auth boundary. Return null to reject (handler responds 401). Return { subscriptionId, mode? } to mint a signed token. The handler never reads your auth state directly — you own that logic entirely.

Prerequisites

  • createUnchurnHandler imported from @unchurn.dev/widget/server
  • One of the auth systems below already configured in your project
  • A way to look up the user’s stripeSubscriptionId from your database

Clerk

// app/api/unchurn/token/route.ts import { createUnchurnHandler } from '@unchurn.dev/widget/server' import { auth } from '@clerk/nextjs/server' import { db } from '@/lib/db' export const POST = createUnchurnHandler({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, resolveUser: async () => { const { userId } = await auth() if (!userId) return null const user = await db.user.findUnique({ where: { clerkId: userId }, select: { stripeSubscriptionId: true }, }) if (!user?.stripeSubscriptionId) return null return { subscriptionId: user.stripeSubscriptionId } }, })

auth() from @clerk/nextjs/server reads the request context automatically — you do not need to pass req. Return null if userId is absent (unauthenticated) or if the user has no subscription (handler responds 401 in both cases).


NextAuth / Auth.js

// app/api/unchurn/token/route.ts import { createUnchurnHandler } from '@unchurn.dev/widget/server' import { auth } from '@/auth' import { db } from '@/lib/db' export const POST = createUnchurnHandler({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, resolveUser: async () => { const session = await auth() if (!session?.user?.id) return null const user = await db.user.findUnique({ where: { id: session.user.id }, select: { stripeSubscriptionId: true }, }) if (!user?.stripeSubscriptionId) return null return { subscriptionId: user.stripeSubscriptionId } }, })

Auth.js v5 auth() resolves the session from the cookie automatically when called inside a route handler. Earlier Auth.js / NextAuth v4 versions use getServerSession(authOptions) from next-auth/next instead.


Supabase Auth

// app/api/unchurn/token/route.ts import { createUnchurnHandler } from '@unchurn.dev/widget/server' import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' import { db } from '@/lib/db' export const POST = createUnchurnHandler({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, resolveUser: async () => { const cookieStore = await cookies() const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { getAll: () => cookieStore.getAll(), setAll: () => {}, }, }, ) const { data: { user } } = await supabase.auth.getUser() if (!user) return null const profile = await db.profile.findUnique({ where: { supabaseId: user.id }, select: { stripeSubscriptionId: true }, }) if (!profile?.stripeSubscriptionId) return null return { subscriptionId: profile.stripeSubscriptionId } }, })

createServerClient reads the Supabase session from the Next.js cookie store. getUser() makes a network round-trip to verify the token — prefer it over getSession() for server-side auth checks. The older @supabase/auth-helpers-nextjs package still works but has been soft-deprecated; @supabase/ssr is the current recommendation.


Custom header-based auth

For internal tools, mobile apps, or service-to-service calls that pass a bearer token in a header:

// app/api/unchurn/token/route.ts import { createUnchurnHandler } from '@unchurn.dev/widget/server' import { verifyInternalToken } from '@/lib/tokens' import { db } from '@/lib/db' export const POST = createUnchurnHandler({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, resolveUser: async (req) => { const header = req.headers.get('authorization') ?? '' const bearer = header.startsWith('Bearer ') ? header.slice(7) : null if (!bearer) return null const claims = await verifyInternalToken(bearer) if (!claims?.userId) return null const user = await db.user.findUnique({ where: { id: claims.userId }, select: { stripeSubscriptionId: true }, }) if (!user?.stripeSubscriptionId) return null return { subscriptionId: user.stripeSubscriptionId } }, })

req is a standard Requestreq.headers.get(name) works in App Router route handlers and in any Node 18+ Request-compatible context. Always verify the bearer token (or whatever credential you read) — never trust the header value as identity, and never derive the subscription ID from anything in the request body.


Common pitfalls

Returning a subscription ID that belongs to a different user. resolveUser is the trust boundary. If a bug causes you to return the wrong subscription ID, the widget will mint a valid token for that subscription. Always derive the subscription ID from the authenticated user’s identity, not from a request body parameter.

Not handling the case where the user has no subscription. Return null when stripeSubscriptionId is absent. The handler responds 401, which useUnchurn surfaces as hook.error. Show the user a message rather than letting the hook silently stay in isReady: false.

Throwing inside resolveUser. If your database call throws, the handler returns 500. The error message is logged server-side only — nothing leaks to the browser. Add your own error handling inside resolveUser if you need granular fallback behavior.


Next steps