Auth Tokens
Why auth tokens?
By default, the cancel flow only requires a merchantId and subscriptionId. Any browser that knows a customer’s subscriptionId can open the flow for that subscription — including a curious customer trying to manipulate their own account, or a malicious actor who has guessed or scraped subscription IDs.
Auth tokens close that gap. Your server mints a short-lived, HMAC-signed token that cryptographically binds the session to a specific subscription. The Unchurn backend verifies the signature on every request and rejects tokens that have been forged, mutated, or replayed after expiry. This is called Locked mode.
How it works
- Your customer clicks “Cancel subscription.”
- Your client calls your token endpoint (e.g.
POST /api/unchurn/token). - Your server authenticates the request (session cookie, JWT, etc.), looks up the customer’s Stripe subscription ID, and calls
createUnchurnHandlerwhich mints and signs a token. - The token is returned to the browser and passed to
showCancelFlow(or handled automatically byuseUnchurn). - The Unchurn backend verifies the HMAC signature, checks the expiry, and confirms the
subscriptionIdin the token matches the session. Forgeries and expired tokens are rejected with a 401.
Browser Your server Unchurn backend
| | |
|-- POST /api/unchurn/token --> |
| | |
| authenticate user |
| look up subscriptionId |
| mint + sign token |
| | |
|<-- { authToken, ... } -| |
| | |
|-- showCancelFlow(authToken) -----------------> |
| | verify HMAC |
| | check expiry |
| | open session |
|<----------------------------------------- OK |Token format
A token has three parts: a mode prefix, a base64url payload, and a hex signature.
unch_<mode>_<base64url(merchantId:subscriptionId:mode:expMs)>.<hex-hmac-sha256>The prefix is either unch_test_ or unch_live_ depending on the Stripe environment the token targets — same self-describing pattern as Stripe’s own sk_test_… / sk_live_…. It’s purely presentational: the HMAC signature is computed over the base64url payload only, never the prefix.
Examples (signatures truncated):
unch_live_bWNoX2FiYzpzdWJfeHl6OmxpdmU6MTc0NTAwMDAwMDAwMA.1a2b3c4d…
unch_test_bWNoX2FiYzpzdWJfeHl6OnRlc3Q6MTc0NTAwMDAwMDAwMA.9f8e7d6c…Decoded payload:
mch_abc123:sub_1PqXyz:live:1745000000000You do not need to construct tokens manually — createUnchurnHandler does this for you. The format is documented here so you can inspect or debug tokens during development.
Mode prefix parity
The prefix is enforced on the verifier side:
- Prefix mode must match the payload mode. A token wrapped in
unch_test_whose payload claimsmode: live(or vice versa) is rejected with a 401unauthorized. This defends against prefix-swap tamper even when the HMAC is otherwise valid. - Prefix mode must match the request mode. A
unch_test_token sent to a live-mode request is rejected, and vice versa. Test-mode tokens cannot trigger live Stripe mutations.
See the quickstart for the minimum integration.
Legacy unprefixed tokens
During the migration window, tokens in the older unprefixed form
base64url(…).hex-hmac-sha256are still accepted, but every such verification emits a warn-level log (auth_token.unprefixed_legacy). Upgrade @unchurn.dev/widget to the current version and redeploy your token route — the SDK will automatically mint prefixed tokens after that. Once the migration window closes, unprefixed tokens will be rejected.
Setting up the token endpoint
Install the package
npm install @unchurn.dev/widgetAdd environment variables
# .env.local
UNCHURN_SECRET=your_32_char_hex_secret_from_dashboard
UNCHURN_MERCHANT_ID=mch_YOUR_MERCHANT_IDCreate the route handler
// app/api/unchurn/token/route.ts
import { createUnchurnHandler } from '@unchurn.dev/widget/nextjs'
import { getCurrentUser } from '@/lib/auth' // your own session helper
export const POST = createUnchurnHandler({
secret: process.env.UNCHURN_SECRET!,
merchantId: process.env.UNCHURN_MERCHANT_ID!,
resolveUser: async (req) => {
const user = await getCurrentUser(req)
if (!user) return null // → 401, empty body
return {
subscriptionId: user.stripeSubscriptionId,
mode: 'live', // 'test' | 'live' — omit to default to 'live'
}
},
tokenTtlSeconds: 300, // optional, default 300, max 600
})resolveUser receives the raw Request object. Return null to send a 401. Throw an error and the handler returns a 500 — the error message is never forwarded to the client, only logged server-side.
Successful response shape
{
"authToken": "unch_live_base64url(...).abc123...",
"expiresAt": "2026-04-22T12:05:00.000Z",
"merchantId": "mch_abc123",
"subscriptionId": "sub_1PqXyz",
"mode": "live"
}Using the React hook
useUnchurn manages token fetching, in-memory caching, and deduplication automatically.
// components/subscription-management.tsx
'use client'
import { useUnchurn } from '@unchurn.dev/widget/react'
export function SubscriptionManagement() {
const { show, isReady, error } = useUnchurn({
tokenEndpoint: '/api/unchurn/token',
// lazy: false, // default — pre-fetches token on mount
})
if (error) {
return <p>Unable to load cancellation options. Please contact support.</p>
}
return (
<button onClick={show} disabled={!isReady}>
Cancel subscription
</button>
)
}Hook options
| Option | Type | Default | Description |
|---|---|---|---|
tokenEndpoint | string | required | URL of your POST token endpoint |
lazy | boolean | false | When false, fetches the token on mount. When true, defers until show() is called. |
Hook return values
| Value | Type | Description |
|---|---|---|
show | () => Promise<void> | Opens the cancel flow. Fetches a token first if one is not cached. |
close | () => void | Programmatically closes the widget. |
isReady | boolean | true once the initial token fetch resolves successfully. |
error | Error | null | Set if the token fetch fails. |
refresh | () => Promise<void> | Force-refetches a new token, bypassing the cache. |
The hook caches the token in memory and automatically refreshes it 30 seconds before it expires. Rapid-fire show() calls are deduplicated — only one token request is in-flight at a time. Unmounting the component aborts any in-flight request.
Lazy mode
With lazy: true, no token request is made until the user actually clicks the button. Use this when you want to avoid a network request on every page load and are comfortable with a small delay on click.
const { show, error } = useUnchurn({
tokenEndpoint: '/api/unchurn/token',
lazy: true,
})Manual token passing
For non-React setups, fetch the token yourself and pass it directly to showCancelFlow.
import { showCancelFlow } from '@unchurn.dev/widget'
async function handleCancelClick() {
const res = await fetch('/api/unchurn/token', { method: 'POST' })
if (!res.ok) {
console.error('Failed to fetch auth token', res.status)
return
}
const { authToken } = await res.json()
showCancelFlow({
merchantId: 'mch_YOUR_MERCHANT_ID',
subscriptionId: 'sub_STRIPE_SUB_ID',
authToken,
mode: 'live',
})
}
document.getElementById('cancel-btn')?.addEventListener('click', handleCancelClick)You are responsible for re-fetching the token if it expires before the user clicks.
Token TTL
The default TTL is 300 seconds (5 minutes). The maximum is 600 seconds (10 minutes).
createUnchurnHandler({
// ...
tokenTtlSeconds: 300, // default
})Recommendation: keep TTL short (the default is fine) and use lazy: false (the hook default) so the token is fetched on page load and is already valid when the customer clicks cancel. Short-lived tokens limit the damage if a token is intercepted.
Error handling
When a session fails auth, the Unchurn backend returns a 401 with code: 'unauthorized'. All token failures (missing in locked mode, invalid HMAC, expired expMs, mode/merchant/sub mismatch, signing secret not configured) collapse to this single code. The response message carries the specific cause for logging.
When using useUnchurn, token refresh is handled by the hook — stale tokens trigger an automatic re-fetch on the next show() call. You can also call refresh() manually to force a fresh token.
Locked mode
By default, merchants run in Open mode: an authToken is accepted if provided but not required. Enable Locked mode in your Unchurn dashboard under Settings > Security. Once enabled, every cancel flow session must include a valid authToken. Sessions without one are immediately rejected with a 401 unauthorized.
Locked mode is the recommended setting for production. It prevents any browser-side manipulation of the cancel flow.