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 via useEffect (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 partner extracts 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-IP header. Falls back to the first entry of X-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: 60 on 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-After header.

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>&region=<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. Keeping noopener sponsored is 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-session request that returns { sessionToken, expiresAt }.

  • Name
    DevTools — Click chain
    Description

    Click a placement. You should see your /c/<provider> route 302 to track.bitcompare.net/...?session=...&region=... (with no partner=), then 302 to the provider.

  • Name
    BitCompare analytics
    Description

    Within 24h of deploy your authMethod breakdown will start showing session clicks. 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 curl of 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-session and 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/session with 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 as X-Real-IP in 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.

Was this page helpful?