Skip to Content
ReferenceToken format

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>
SegmentFormatExample
Prefixunch_test_ or unch_live_unch_live_
Payloadbase64url(merchantId:subscriptionId:mode:expMs)bWNoX3h4eDpzdWJfeHh4OmxpdmU6MTcwMDAwMDAwMDAwMA
Separator..
Signature64-char lowercase hex HMAC-SHA256a3f9...

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:

PositionClaimTypeFormatExample
0merchantIdstring[A-Za-z0-9_-]+mch_xxx
1subscriptionIdstring[A-Za-z0-9_-]+sub_1Pxx
2mode'test' | 'live'literallive
3expMsnumberdecimal integer1700000000000

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 + body

The 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

ConstantValueDescription
WIDGET_AUTH_DEFAULT_TTL_SECONDS300Default TTL (5 minutes)
WIDGET_AUTH_MAX_TTL_SECONDS600Hard 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 } | null

Verification steps (in order):

  1. Reject if token is not a string or is empty
  2. Reject if token.length > 512 (DoS guard)
  3. Strip mode prefix if present (unch_test_ / unch_live_); record prefixMode
  4. Split on . — reject if no dot, leading dot, trailing dot, or second dot
  5. Validate signature against WIDGET_AUTH_HEX64_PATTERN (/^[0-9a-f]{64}$/) before Buffer.from — the decoder silently drops invalid nibbles
  6. Validate payload against WIDGET_AUTH_BASE64URL_PATTERN (/^[A-Za-z0-9_-]+$/) before decode — rejects standard base64 + / /
  7. Compute expected = HMAC-SHA256(secret, payloadB64).hex(); compare via crypto.timingSafeEqual — constant-time, prevents timing attacks
  8. Decode payload, split on :, reject if part count ≠ 4
  9. Validate mode against ['test', 'live']
  10. Validate expMs against /^[0-9]+$/; reject if expMs <= 0 or Date.now() >= expMs (expired)
  11. Validate merchantId and subscriptionId against WIDGET_AUTH_ID_PATTERN (/^[A-Za-z0-9_-]+$/)
  12. If prefixMode is present, reject if prefixMode !== 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

PropertyDetail
No per-token replay storeA captured token is replayable for the full TTL window
RotationRotate UNCHURN_SECRET from the dashboard to invalidate all outstanding tokens immediately
Mode tamper detectionunch_live_ prefix wrapping a test-mode payload is rejected
Supply-chain hardeningwindow.unchurn installed as non-configurable, non-writable — third-party scripts cannot hijack it
Constant-time comparisoncrypto.timingSafeEqual on fixed-length 32-byte buffers

Constants (from @unchurn/contracts)

ConstantValueDescription
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_LENGTH512Maximum token string length
WIDGET_AUTH_PAYLOAD_PART_COUNT4Colon-delimited segments
WIDGET_AUTH_DEFAULT_TTL_SECONDS300Default TTL
WIDGET_AUTH_MAX_TTL_SECONDS600Maximum TTL

Last updated on