Session Tokens
Short-lived, IP-bound replacement for the legacy ?partner=<token> query parameter used to attribute Ad Network clicks. The legacy token is a long-lived bearer credential rendered in plain text in every placement; scrapers can extract it from HTML and replay clicks indefinitely. Session tokens fix that by minting fresh credentials per visitor that never appear in initial HTML and expire after 5 minutes.
What's changing
- Name
Token lifetime- Description
Legacy
?partner=lives forever. Session tokens expire after 5 minutes by default; the browser refreshes on tab focus.
- Name
Token visibility- Description
Legacy tokens render in every
<a href>of your HTML. Session tokens are minted client-side viauseEffect(or equivalent) and never appear in the initial response.
- Name
Token portability- Description
Legacy tokens can be replayed from any IP. Session tokens are signed against the visitor's IP at mint time; replay from a different IP fails verification.
- Name
Page-source scraping risk- Description
Legacy: high —
curl your-site.com | grep partnerextracts the credential. Session tokens: none — nothing exploitable in the initial HTML.
Both paths work today. Once you've migrated, your account manager will set a deprecation date for ?partner= on your account with ≥30 days notice.
The flow
Browser Your edge function track.bitcompare.net
│ │ │
│ Page loads (no token │ │
│ in initial HTML) │ │
├───────────────────────────►│ │
│ HTML with <a href="/c/X"> │ │
│◄───────────────────────────┤ │
│ │ │
│ useEffect fires: │ │
│ POST /api/bc-session │ │
├───────────────────────────►│ │
│ │ POST /tracking/session │
│ │ (attaches partner + │
│ │ X-Real-IP) │
│ ├───────────────────────────►│
│ │ { sessionToken, │
│ │ expiresAt } │
│ │◄───────────────────────────┤
│ { sessionToken, │ │
│ expiresAt } │ │
│◄───────────────────────────┤ │
│ │ │
│ Rewrites all │ │
│ <a href> to include │ │
│ &session=<token> │ │
│ │ │
│ User clicks │ │
│ GET /c/<provider>?... │ │
├───────────────────────────►│ │
│ │ GET /tracking/clicks/ │
│ │ <provider>/<code>?... │
│ ├───────────────────────────►│
│ │ │ Verifies session
│ │ │ (sig + IP + TTL)
│ │ │ → attributes to
│ │ │ your consumer
│ │ │ → 302 to provider
The mint endpoint
POST /api/v1/tracking/session
Authenticates via your existing partner token (now scoped to server-side only — your edge function attaches it before proxying to us).
- Name
Auth- Description
?partner=<your_partner_token>query param. Keep this token in a server-only secret store (Netlify env var, Vercel env var, etc.) — it must never appear in client-side code or HTML.
- Name
Visitor IP- Description
Pass via
X-Real-IPheader. Falls back to the first entry ofX-Forwarded-For, then to the connecting socket address. Most edge runtimes (Netlify, Cloudflare, Vercel) expose the real visitor IP natively.
- Name
Rate limit- Description
10 mints per 60 seconds per (consumer, visitor IP). Includes
Retry-After: 60on 429.
Response 200:
{
"data": {
"sessionToken": "v1.42.1778638400.abc123def456.0011223344556677889900aabbccddeeff",
"expiresAt": "2026-05-13T02:03:23.000Z"
}
}
Errors:
- Name
401 Unauthorized- Description
Partner token missing or invalid.
- Name
429 Too Many Requests- Description
Rate limit exceeded for this (consumer, IP). Wait and retry after the seconds in the
Retry-Afterheader.
Reference implementation
1. Strip the partner token out of your HTML
Before — token in every <a href>:
<a
href="https://track.bitcompare.net/api/v1/tracking/clicks/<provider>/<short_code>?partner=<your_token>®ion=<visitor_country>"
target="_blank"
rel="noopener noreferrer sponsored"
>
Provider Name
</a>
After — relative URL to your own proxy, no token visible:
<a
href="/c/<provider>?category=lending"
target="_blank"
rel="noopener sponsored"
data-bc-link
>
Provider Name
</a>
Two rel changes:
- Drop
noreferrer— it strips the Referer header for all your clicks, which kills our per-page attribution on the BitCompare side. Keepingnoopener sponsoredis sufficient for security and disclosure compliance. - Add
data-bc-link— JS attribute used by the React hook below to find the placement anchors and rewrite their hrefs.
2. Mint a session token client-side
A React hook that fetches a session token from your own /api/bc-session endpoint and rewrites all <a data-bc-link> hrefs to include it. Refreshes on tab focus when the token has under 60 seconds left, so tabs left open across the lunch break get a fresh token before the next click.
import { useEffect, useRef, useState } from 'react'
interface SessionToken {
token: string
expiresAtMs: number
}
export function useBitCompareSession() {
const [session, setSession] = useState<SessionToken | null>(null)
// sessionRef holds the latest token without re-running the effect on
// every change — needed because the focus handler is registered once
// and would otherwise close over a stale `session` value.
const sessionRef = useRef<SessionToken | null>(null)
sessionRef.current = session
useEffect(() => {
let cancelled = false
async function mint() {
try {
const res = await fetch('/api/bc-session', { method: 'POST' })
if (!res.ok) return
const body = await res.json()
if (cancelled) return
setSession({
token: body.data.sessionToken,
expiresAtMs: new Date(body.data.expiresAt).getTime(),
})
} catch {
// Mint failure → links remain un-tokenized. Click will not be
// attributed but will still redirect to the provider.
}
}
mint()
const onFocus = () => {
const current = sessionRef.current
if (!current || current.expiresAtMs - Date.now() < 60_000) {
mint()
}
}
window.addEventListener('focus', onFocus)
return () => {
cancelled = true
window.removeEventListener('focus', onFocus)
}
}, [])
// Whenever we have a token, rewrite the placements' hrefs to include it.
useEffect(() => {
if (!session) return
document
.querySelectorAll<HTMLAnchorElement>('a[data-bc-link]')
.forEach((a) => {
const url = new URL(
a.getAttribute('href') ?? '',
window.location.origin,
)
url.searchParams.set('session', session.token)
a.setAttribute('href', url.pathname + url.search)
})
}, [session])
return session
}
Mount once per page in your top-level layout:
function Layout({ children }: { children: React.ReactNode }) {
useBitCompareSession()
return <>{children}</>
}
3. Edge function: proxy /api/bc-session and /c/<provider>
This is where your partner secret lives — server-side only. Netlify Edge Functions example:
// netlify/edge-functions/bc-proxy.ts
import type { Context } from '@netlify/edge-functions'
const PARTNER_TOKEN = Deno.env.get('BITCOMPARE_PARTNER_TOKEN')!
const PROVIDER_SHORT_CODES: Record<string, string> = {
// Map each provider ID to the persistent short code from your BitCompare
// dashboard. These are public per-link identifiers; only the partner
// token needs to stay secret. Replace these placeholders with your
// actual short codes before deploying.
'provider-one': 'placeholder1',
'provider-two': 'placeholder2',
// ...
}
export default async function (
request: Request,
context: Context,
): Promise<Response> {
const url = new URL(request.url)
const visitorIp = context.ip
// POST /api/bc-session — mint a session token via bitcompare
if (url.pathname === '/api/bc-session' && request.method === 'POST') {
const mintUrl = new URL(
'https://track.bitcompare.net/api/v1/tracking/session',
)
mintUrl.searchParams.set('partner', PARTNER_TOKEN)
const upstream = await fetch(mintUrl, {
method: 'POST',
headers: { 'X-Real-IP': visitorIp },
})
return new Response(await upstream.text(), {
status: upstream.status,
headers: { 'Content-Type': 'application/json' },
})
}
// GET /c/<provider>?category=...&session=...
// Redirect to track.bitcompare.net with the session token + region.
const match = url.pathname.match(/^\/c\/([a-z0-9-]+)$/)
if (match && request.method === 'GET') {
const providerId = match[1]
const shortCode = PROVIDER_SHORT_CODES[providerId]
if (!shortCode) return new Response('Not found', { status: 404 })
const trackUrl = new URL(
`https://track.bitcompare.net/api/v1/tracking/clicks/${providerId}/${shortCode}`,
)
// Pass through caller-supplied params (category, coin, session, etc.)
for (const [k, v] of url.searchParams) trackUrl.searchParams.set(k, v)
// Attach the geo region from the edge (visitor's country)
trackUrl.searchParams.set('region', context.geo?.country?.code ?? 'XX')
return Response.redirect(trackUrl.toString(), 302)
}
return context.next()
}
export const config = { path: ['/api/bc-session', '/c/*'] }
The export const config covers routing — no netlify.toml [[edge_functions]] block is needed.
4. Store the partner token as a secret environment variable
In Netlify: Site settings → Environment variables → Add variable. Scope to "Functions and Edge Functions". Never commit this value to your repo.
Verification checklist
After deploying the above:
- Name
View source- Description
Search the page source for
partner=. Should return zero matches.
- Name
DevTools — Network tab- Description
Reload the page. You should see a
POST /api/bc-sessionrequest that returns{ sessionToken, expiresAt }.
- Name
DevTools — Click chain- Description
Click a placement. You should see your
/c/<provider>route 302 totrack.bitcompare.net/...?session=...®ion=...(with nopartner=), then 302 to the provider.
- Name
BitCompare analytics- Description
Within 24h of deploy your
authMethodbreakdown will start showingsessionclicks. Request a per-day report from your account manager to track migration progress.
Threat model
What this defeats
- Name
Scraper-replay- Description
Token never appears in page source. Only an executing browser can obtain one — a
curlof your HTML returns nothing exploitable.
- Name
Cross-IP replay- Description
Token signature includes a hash of the visitor's IP. Replay from a different network fails verification immediately.
- Name
Time-shifted replay- Description
5-minute TTL. Tokens captured during a session expire long before they can be replayed at scale.
What this does not defeat
These residual threats are handled by BitCompare's server-side fraud detection (datacenter ASN deny list, Tor exit deny list, IP-velocity rate limits, UA-rotation detection):
- Name
Real-browser bots- Description
Headless Chrome / Playwright executing JS can still hit your
/api/bc-sessionand obtain tokens. Caught by our datacenter / Tor / UA-rotation gates regardless of token validity.
- Name
In-TTL bursts- Description
A bot that gets a fresh token every minute can still flood your
/c/*endpoint until the rate limit kicks in. Bound this on your side: rate-limit/c/*at the Netlify edge (recommend 20/min/IP for clicks, 10/min/IP for session mints).
FAQ
- Name
Do I have to migrate immediately?- Description
No. The
?partner=path keeps working. Your account manager will give you ≥30 days notice before deprecating it for your account.
- Name
What happens if mint fails?- Description
The page renders without session tokens. Clicks still redirect to providers — they just won't be attributed to your consumer until the next mint succeeds. Plan to handle the "no token yet" state in your hook (the React example silently no-ops).
- Name
Can I cache the session token in localStorage?- Description
Avoid this. The whole point of the short TTL + IP binding is to limit blast radius if a token leaks. Storing it client-side widens the leak window. Mint per page load instead — it's cheap (5–20ms p50 on our side).
- Name
Does this work without JavaScript?- Description
No — the mint requires JS to execute. If your audience has significant no-JS users (accessibility-first audiences, very old browsers), consider a
<noscript>block with the legacy?partner=URL as a fallback. This trades the migration's security gains for those visitors but maintains attribution.
- Name
What about SSR / Next.js App Router?- Description
You can mint server-side during the initial render too. Your server route hits
/api/v1/tracking/sessionwith the partner token attached, and you inject the token into the rendered page (via a<script>tag or component prop). Hydration takes over from there. Important: the token is bound to the visitor's IP, so SSR must forward the visitor's IP asX-Real-IPin the mint request.
Where you'll see the auth method
Every attributed click logs the auth method on the BitCompare side:
{
"providerId": "<provider-id>",
"apiConsumerId": 42,
"authMethod": "session"
}
Your account manager can pull a per-day adoption report — useful for tracking how much of your traffic is on the new path before the legacy ?partner= deprecation date.