Token format
The HMAC auth token structure used by createUnchurnHandler and verified by the Unchurn backend.
Token structure
unch_<mode>_<base64url(merchantId:subscriptionId:mode:expMs)>.<hex-hmac-sha256>| Segment | Format | Example |
|---|---|---|
| Prefix | unch_test_ or unch_live_ | unch_live_ |
| Payload | base64url(merchantId:subscriptionId:mode:expMs) | bWNoX3h4eDpzdWJfeHh4OmxpdmU6MTcwMDAwMDAwMDAwMA |
| Separator | . | . |
| Signature | 64-char lowercase hex HMAC-SHA256 | a3f9... |
The prefix is presentational only (Stripe-style self-describing secret for log readability). The HMAC is computed over the encoded payload only — not the prefix. The verifier strips the prefix before computing the expected signature.
Backward compatibility: unprefixed tokens (<payload>.<signature>) are still accepted during a migration window. The backend logs these at warn with scope auth_token.unprefixed_legacy.
Claims
Four canonical fields in fixed positional order, colon-joined, then base64url-encoded:
| Position | Claim | Type | Format | Example |
|---|---|---|---|---|
| 0 | merchantId | string | [A-Za-z0-9_-]+ | mch_xxx |
| 1 | subscriptionId | string | [A-Za-z0-9_-]+ | sub_1Pxx |
| 2 | mode | 'test' | 'live' | literal | live |
| 3 | expMs | number | decimal integer | 1700000000000 |
Colons in merchantId or subscriptionId break the parser — createUnchurnHandler validates both against [A-Za-z0-9_-]+ at mint time and fails closed if resolveUser returns a value with invalid characters.
Signing algorithm
payload_raw = merchantId + ":" + subscriptionId + ":" + mode + ":" + expMs
payload_b64 = base64url(Buffer.from(payload_raw, "utf-8"))
signature = HMAC-SHA256(secret, payload_b64).hex()
body = payload_b64 + "." + signature
token = modePrefix + bodyThe key is the merchant’s UNCHURN_SECRET (raw string, UTF-8 encoded by Node createHmac).
The HMAC input is the base64url-encoded payload, not the raw string. This matches the Stripe / AWS SigV4 convention: signing the encoded form keeps parse and verify deterministic across encodings.
Implemented in packages/widget/src/nextjs.ts:
// packages/widget/src/nextjs.ts
export function encodePayload(params: {
merchantId: string;
subscriptionId: string;
mode: 'test' | 'live';
expMs: number;
}): string {
const raw = `${params.merchantId}:${params.subscriptionId}:${params.mode}:${params.expMs}`;
return Buffer.from(raw, 'utf-8').toString('base64url');
}
export function signPayload(secret: string, payloadB64: string): string {
return createHmac('sha256', secret).update(payloadB64).digest('hex');
}Token TTL
| Constant | Value | Description |
|---|---|---|
WIDGET_AUTH_DEFAULT_TTL_SECONDS | 300 | Default TTL (5 minutes) |
WIDGET_AUTH_MAX_TTL_SECONDS | 600 | Hard upper bound (10 minutes) |
tokenTtlSeconds in CreateUnchurnHandlerOptions sets the TTL. Values outside [1, 600] throw at handler-construction time. The backend rejects tokens with expMs more than 600 seconds in the future.
The React hook (useUnchurn) applies a 30-second safety buffer before expiry: tokens within 30 seconds of expiry are treated as stale and a fresh fetch is triggered before opening the widget.
Verification
verifyAuthToken(token, secret) from @unchurn.dev/widget/nextjs:
// packages/widget/src/nextjs.ts
export function verifyAuthToken(
token: string,
secret: string,
): { merchantId: string; subscriptionId: string; mode: 'test' | 'live'; expMs: number } | nullVerification steps (in order):
- Reject if
tokenis not a string or is empty - Reject if
token.length > 512(DoS guard) - Strip mode prefix if present (
unch_test_/unch_live_); recordprefixMode - Split on
.— reject if no dot, leading dot, trailing dot, or second dot - Validate signature against
WIDGET_AUTH_HEX64_PATTERN(/^[0-9a-f]{64}$/) beforeBuffer.from— the decoder silently drops invalid nibbles - Validate payload against
WIDGET_AUTH_BASE64URL_PATTERN(/^[A-Za-z0-9_-]+$/) before decode — rejects standard base64+// - Compute
expected = HMAC-SHA256(secret, payloadB64).hex(); compare viacrypto.timingSafeEqual— constant-time, prevents timing attacks - Decode payload, split on
:, reject if part count ≠ 4 - Validate
modeagainst['test', 'live'] - Validate
expMsagainst/^[0-9]+$/; reject ifexpMs <= 0orDate.now() >= expMs(expired) - Validate
merchantIdandsubscriptionIdagainstWIDGET_AUTH_ID_PATTERN(/^[A-Za-z0-9_-]+$/) - If
prefixModeis present, reject ifprefixMode !== mode(mode-swap tamper detection)
Returns structured claims or null. Never distinguishes bad-signature from malformed-payload — callers cannot tell which check failed.
Security properties
| Property | Detail |
|---|---|
| No per-token replay store | A captured token is replayable for the full TTL window |
| Rotation | Rotate UNCHURN_SECRET from the dashboard to invalidate all outstanding tokens immediately |
| Mode tamper detection | unch_live_ prefix wrapping a test-mode payload is rejected |
| Supply-chain hardening | window.unchurn installed as non-configurable, non-writable — third-party scripts cannot hijack it |
| Constant-time comparison | crypto.timingSafeEqual on fixed-length 32-byte buffers |
Constants (from @unchurn/contracts)
| Constant | Value | Description |
|---|---|---|
WIDGET_AUTH_ID_PATTERN | /^[A-Za-z0-9_-]+$/ | Allowlist for merchant and subscription IDs |
WIDGET_AUTH_HEX64_PATTERN | /^[0-9a-f]{64}$/ | HMAC hex digest format |
WIDGET_AUTH_BASE64URL_PATTERN | /^[A-Za-z0-9_-]+$/ | URL-safe base64 allowlist (no + / /) |
WIDGET_AUTH_EXPMS_PATTERN | /^[0-9]+$/ | Decimal integer only |
WIDGET_AUTH_MODES | ['test', 'live'] | Valid mode values |
WIDGET_AUTH_TOKEN_MAX_LENGTH | 512 | Maximum token string length |
WIDGET_AUTH_PAYLOAD_PART_COUNT | 4 | Colon-delimited segments |
WIDGET_AUTH_DEFAULT_TTL_SECONDS | 300 | Default TTL |
WIDGET_AUTH_MAX_TTL_SECONDS | 600 | Maximum TTL |
Cross-links
- Widget API —
createUnchurnHandler— signing endpoint - Widget API —
verifyAuthToken— verification export - Environment variables —
UNCHURN_SECRET - Architecture — Trust boundaries