Skip to content

Auth and Identity

Ayush Dhiman edited this page May 27, 2026 · 1 revision

Slaze uses a dual-layer identity model: anonymous device tokens (prove "genuine extension install") + Clerk JWTs (prove "real human behind it"). This document explains every auth mechanism, why each exists, how they compose, and what threats each defends against.


Table of Contents

  1. Why Two-Layer Auth
  2. Layer 1: Anonymous Device Tokens
  3. Layer 2: Clerk Identity (JWT)
  4. Request Signing (HMAC)
  5. Quota Enforcement
  6. Token Lifecycle
  7. Threat Model
  8. Internal Auth

Why Two-Layer Auth?

The problem with single-layer approaches

Option A: Anonymous-only β€” Anyone can vote. Problem: no quota differentiation. A botnet operator and a legitimate user look identical. Quotas can't distinguish between them, so either everyone gets generous limits (and the botnet wins) or everyone gets strict limits (and real users are frustrated).

Option B: Login-only β€” Must sign in to do anything. Problem: conversion friction. Most users who install a browser extension will not create an account. Forcing sign-in before showing any value (ratings badges) would kill adoption. The extension must show badges immediately after install.

Option C: Device token + login (Slaze's approach) β€” Anonymous tokens provide immediate read access (50 checks/day). Clerk login provides full access (300 checks/day, 20 votes/day, 600 votes/month). The upgrade path is seamless: install β†’ see badges β†’ try to vote β†’ sign in once β†’ never think about auth again. The Clerk JWT is discarded after the one-time link call.

The design tradeoff

The dual-layer system is more complex than a simple "log in with Google" flow. The cost is:

  • Token creation endpoint (3/IP/day rate limiting)
  • Token validation middleware (cache, DB lookup)
  • Token linking/unlinking flows
  • Inline auto-link on first vote
  • TransferClerkLink: "last login wins" when signing in from new device

The benefit is a gradual engagement model: users get value before committing to sign-in. This is the same model that Reddit itself uses (browse anonymously, sign in to vote/comment).


Layer 1: Anonymous Device Tokens

Token creation

POST /v1/auth/token  (no auth)
  β†’ GenerateToken(): crypto/rand 32 bytes β†’ hex(64 chars)
  β†’ SHA-256(token) β†’ stored in tokens.token_hash
  β†’ IP rate limit: 3 tokens per IP per IST calendar day
  β†’ Auto-create free subscription row
  β†’ Response: { token, tier: "anonymous", plan: "free", quota: {...} }

Token format

64-character hex string. 32 bytes of entropy from crypto/rand. That's 256 bits β€” astronomically larger than the birthday bound. Even with 1 billion tokens, the collision probability is effectively zero.

Why SHA-256 for storage?

The raw token is hashed before storage. This is a security basic: if the database is compromised, the attacker gets hashes, not usable tokens. SHA-256 is a fast hash, but with 256 bits of input entropy, brute-forcing is infeasible regardless of hash speed. A slower hash (bcrypt, scrypt) would add no meaningful security and would slow down every API request that validates a token.

IP-based creation limit

Max 3 tokens per IP per day. This prevents a single IP from generating thousands of tokens for botnets. The query:

SELECT COUNT(*) FROM tokens
WHERE created_ip = $1::inet AND created_at >= (CURRENT_DATE AT TIME ZONE 'Asia/Kolkata')::date

Limitations of IP-based limiting:

  • CGNAT: multiple users behind the same IP (mobile carriers, universities) share the limit. The limit is 3/day, not 1/day, to accommodate this.
  • VPNs/proxies: an attacker can rotate IPs to bypass the limit. This is a known weakness. The defense-in-depth is the velocity burst penalty and the history/consistency gates β€” even if an attacker creates many tokens, their votes carry near-zero weight.
  • Bypass IPs: SLAZE_BYPASS_IPS env var (comma-separated) exempts IPs from all rate limits and the 3-token-per-IP-per-day cap. Used for development and trusted internal IPs.

What an anonymous token can do

Operation Limit Purpose
Read ratings 50/day See badges on posts
Batch read Counts as 1 check Extension page scan
Vote 0/day Must sign in

The read limit is generous enough for casual browsing (50 page views with badges) but low enough that a scraper can't crawl the entire API anonymously. Legitimate heavy users will sign in.

Token storage in the extension

// chrome.storage.local
{
  slaze_auth_token: "a1b2c3...",  // 64-char hex
  slaze_plan_info: {
    tier: "anonymous",
    plan: "free",
    clerkLinked: false,
    dailyChecksLimit: 50,
    dailyVotesLimit: 0,
    monthlyVotesLimit: 0,
    dailyChecksUsed: 0,
    dailyVotesUsed: 0,
    monthlyVotesUsed: 0
  }
}

chrome.storage.local is the extension's persistent key-value store. It survives browser restarts. The token is written once on install and never changes (unless the user signs out and gets a new one).


Layer 2: Clerk Identity (JWT)

Why Clerk?

Clerk provides:

  • Ready-made sign-in UI (email OTP, Google OAuth, etc.)
  • JWT session tokens (RS256, verifiable via JWKS)
  • User management (profile, email verification)
  • Webhook events (user created, updated, deleted)

Alternative: build auth from scratch. Cost: OAuth provider integration (Google, Apple, etc.), session management, JWT issuance/verification, password reset flows, email verification. Clerk provides all of this as a service.

Why not Clerk SDK in the extension?

The @clerk/chrome-extension SDK was tried and removed. Reasons:

  1. Firefox MV2 incompatibility: The SDK's validateManifest check fails because Firefox MV2 doesn't include host_permissions in runtime.getManifest(). The bundled webextension-polyfill returned undefined from storage.get().
  2. CSP violations: Clerk's web workers are blocked by Firefox MV2 CSP.
  3. Bundle size: The SDK adds ~100 KB of JavaScript to the extension.
  4. Single-use: Clerk is only needed at sign-in time (one /auth/link call). Running the full SDK for a one-time operation is wasteful.

Instead, the extension uses chrome.identity.launchWebAuthFlow β€” a Chrome/Firefox API that opens a managed browser window for OAuth flows. The actual Clerk auth happens on the website. The extension receives a JWT through the redirect URL and hands it to /v1/auth/link once.

Clerk JWT verification (server-side)

VerifyClerkToken in clerk.go:196:

1. Parse JWT header β†’ extract kid (key ID)
2. Fetch JWKS from Clerk (cached 1 hour)
3. Look up kid in JWKS
   β”œβ”€ Found β†’ verify RS256 signature with the key
   └─ Not found β†’ invalidate JWKS cache, re-fetch
       β”œβ”€ Found after re-fetch β†’ verify
       └─ Still not found β†’ try static JWKS_PUBLIC_KEY fallback
4. Verify claims: exp (not expired), nbf (valid now), iss (matches expected issuer)
5. Return (clerkUserID, email)

Three-layer JWKS resolution

This is the most defensive part of the auth system:

Layer 1: Primary JWKS fetch β€” GET from CLERK_JWKS_URL (or derived from CLERK_FRONTEND_API). Cached 1 hour. This is the normal path.

Layer 2: Rotation re-fetch β€” If an unknown kid appears (Clerk rotated keys), invalidate the 1-hour cache and re-fetch immediately. Handles key rotation without downtime.

Layer 3: Static PEM fallback β€” If JWKS is completely unreachable (Clerk outage, network partition, rate limiting), a PEM-encoded RSA public key from JWKS_PUBLIC_KEY env var provides a last-resort fallback. The key is used without kid matching β€” any RS256-signed JWT from Clerk's issuer is trusted if it verifies with this key. A slog.Warn is emitted when the static key is used so operators know JWKS is unreachable.

Why bother with three layers? In 2024, Clerk had a 4-hour JWKS outage that broke all JWT verification for services that only fetched once. The static PEM fallback means Slaze survives a Clerk JWKS outage. The warning log means operators know it's happening and can investigate.

Token linking

LinkTokenToClerk in models.go:519:

UPDATE tokens SET
    tier = CASE WHEN tier = 'anonymous' THEN 'email' ELSE tier END,
    clerk_user_id = $2,
    email = CASE WHEN $3 <> '' THEN $3 ELSE email END
WHERE token_hash = $1

The unique partial index on clerk_user_id enforces "one Clerk user = one device token." If the Clerk user is already linked to a different token, the UPDATE hits a unique violation β†’ TransferClerkLink is called.

TransferClerkLink: "last login wins"

// 1. Downgrade the OLD token: tier='anonymous', clerk_user_id=NULL
// 2. Upgrade the NEW token: tier='email', clerk_user_id=$clerkUserID
// 3. Move all subscriptions from old to new token

This handles the "new device" scenario: user installs the extension on a new laptop, signs in. Their old device's token is downgraded (keeps usage counters), the new device gets the Clerk identity, and paid subscriptions follow the user. The old device can still browse with 50 checks/day as an anonymous token.

Inline auto-link

The QuotaMiddleware (middleware.go:334) auto-links anonymous tokens that present a valid X-Clerk-Token JWT. This handles the case where a user signs in on the website but hasn't opened the extension β€” their first vote attempt triggers the link transparently.

On link failure (unique constraint β€” Clerk user already linked to another device token), the middleware sets token.Tier = "email" in-memory and recomputes quota. The Clerk JWT proves the user's identity even if the DB link failed. This prevents a broken experience where a user who signed in on a different device but is using this device can't vote.

Unlinking (sign-out)

POST /v1/auth/unlink downgrades tier to anonymous and clears clerk_user_id. Unlike the old flow (which created a new anonymous token), unlink preserves usage counters. This means re-login shows the same usage state β€” a user can't bypass quotas by signing out and back in.

The handler is self-contained: it parses the Authorization header directly (not via TokenAuthMiddleware). Response includes the current usage counters read from the database (dailyChecks, dailyVotes, monthlyVotes), quota limits for anonymous tier, and plan = "free". Both token cache and subscription cache are invalidated after the downgrade.


Request Signing (HMAC)

Why HMAC signing?

Bearer tokens prove "this request carries token X." But anyone who extracts token X from the extension's source code or network traffic can use it. HMAC signing proves "this request came from a genuine extension build."

The signing secret (SLAZE_API_SECRET) is inlined into the extension at build time. It's shared between extension and backend. A request that carries a valid token but no valid HMAC signature is rejected β€” it's from a script that somehow obtained a token, not from the real extension.

Signature computation

payload = METHOD + ":" + PATH + ":" + UNIX_TIMESTAMP + ":" + SHA256_HEX(BODY)
signature = HMAC-SHA256(secret, payload)

Headers:

  • X-Slaze-Ts: Unix timestamp (seconds)
  • X-Slaze-Sig: Hex-encoded HMAC-SHA256

Server-side verification

// 1. Check X-Slaze-Ts is within Β±5 minutes of server time
if abs(time.Now().Unix() - ts) > 300:
    return "timestamp outside window"

// 2. Recompute signature
expected = HMAC-SHA256(secret, method + ":" + path + ":" + ts + ":" + bodyHash)

// 3. Constant-time comparison
if !hmac.Equal(expected, provided):
    return "signature mismatch"

The 5-minute window

The timestamp window prevents replay attacks. An attacker who captures a valid request can only replay it within 5 minutes. After that, the timestamp is stale and the request is rejected.

Why 5 minutes? Short enough to limit replay risk, long enough to tolerate clock skew between client and server. Browser clocks are typically accurate to within a few seconds; 5 minutes is generous.

Threat model limitations

The HMAC secret can be extracted from the extension bundle (it's in the JavaScript source). A determined attacker can:

  1. Download the extension CRX
  2. Unzip it
  3. Find the secret in the bundled JavaScript
  4. Use it to sign arbitrary requests

HMAC signing raises the bar from "anyone with a token" to "anyone who can reverse-engineer the extension." For a community content rating system (not a banking app), this is an acceptable tradeoff. The real defense is the verdict engine's trust weighting β€” even if an attacker signs valid requests, their votes carry near-zero weight due to dwell penalty, account age ramp, history curve, consistency gate, and velocity burst.

Secret rotation

To rotate the secret:

  1. Generate new secret: openssl rand -hex 32
  2. Update SLAZE_API_SECRET on the backend
  3. Update PLASMO_PUBLIC_SLAZE_API_SECRET in the extension
  4. Rebuild and redeploy the extension
  5. Old extension versions will get 401 errors until they auto-update

The Chrome Web Store update cycle is 1–5 days. During this window, old extensions are rejected. This is acceptable for a non-critical service. For a zero-downtime rotation, the backend could accept both old and new secrets during a transition period.

Exempt routes

Route Reason for exemption
/v1/health Public liveness check
/v1/status Public status page
/v1/auth/token No token exists yet
/v1/auth/link Clerk JWT is the auth
/v1/auth/unlink Token auth is sufficient
/v1/me Clerk JWT auth path
/v1/subscriptions Internal (X-Internal-Auth)

Quota Enforcement

Tier-based baselines

Tier Daily Checks Daily Votes Monthly Votes
Anonymous 50 0 0
Email/Phone/Paid 300 20 600

Subscription-based extras

Active subscriptions add to the free baseline. See Pricing-and-Subscriptions.md for full details.

Enforcement order

For vote routes, three limits are checked in order:

  1. Daily vote limit β€” prevents burst abuse within a day
  2. Monthly vote limit β€” total allowance ceiling
  3. Hourly vote limit β€” only when Hour Boost is active

For read routes:

  1. Daily check limit (or hourly if Hour Boost)

Why separate daily and monthly limits? A monthly-only limit of 600 allows a user to cast all 600 votes on day 1, then nothing for 29 days. The daily limit of 20 smooths usage across the month, preventing burst behavior while still allowing consistent daily participation.

Quota headers on every response

The extension never calls a dedicated "quota check" endpoint. It extracts quota info from response headers on existing API calls:

X-Quota-Tier: email
X-Quota-Plan: free
X-Quota-Limit: 20
X-Quota-Used: 8
X-Quota-Remaining: 12

This is zero-overhead quota tracking. The extension's updatePlanFromHeaders() parses these headers from every API response and persists usage counters to chrome.storage.local. The popup reads from storage β€” no extra API calls.

429 responses

When quota is exceeded, the response includes:

  • X-Slaze-Error: "daily vote quota exceeded" (or monthly/hourly)
  • Retry-After: seconds until reset (3600 for daily, 86400 for monthly)
  • Full X-Quota-* headers showing current usage

The 429 body is zero bytes. The error message is in the header. This is consistent with the zero-JSON philosophy.


Token Lifecycle

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        TOKEN LIFECYCLE                           β”‚
β”‚                                                                  β”‚
β”‚  Extension Install                                               β”‚
β”‚        β”‚                                                         β”‚
β”‚        β–Ό                                                         β”‚
β”‚  POST /v1/auth/token                                             β”‚
β”‚        β”‚                                                         β”‚
β”‚        β–Ό                                                         β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                                    β”‚
β”‚  β”‚ ANONYMOUS │── reads: 50/day, votes: 0                         β”‚
β”‚  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜                                                    β”‚
β”‚       β”‚                                                          β”‚
β”‚       β”‚ User signs in (chrome.identity.launchWebAuthFlow)         β”‚
β”‚       β”‚                                                          β”‚
β”‚       β–Ό                                                          β”‚
β”‚  POST /v1/auth/link                                              β”‚
β”‚       β”‚                                                          β”‚
β”‚       β”œβ”€ Success ─────────────────────────┐                      β”‚
β”‚       β”‚                                    β”‚                      β”‚
β”‚       β–Ό                                    β–Ό                      β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”                         TransferClerkLink              β”‚
β”‚  β”‚ EMAIL │── reads: 300/day        (already linked to             β”‚
β”‚  β”‚       β”‚   votes: 20/day          another token β†’               β”‚
β”‚  β”‚       β”‚         600/month        last login wins,              β”‚
β”‚  β””β”€β”€β”€β”¬β”€β”€β”€β”˜                         returns 200)                   β”‚
β”‚      β”‚                                    β”‚                       β”‚
β”‚      β”‚                                    β–Ό                       β”‚
β”‚      β”‚                             β”Œβ”€β”€β”€β”€β”€β”€β”€β”                     β”‚
β”‚      β”‚ Subscription purchase       β”‚ EMAIL β”‚ (same as above)     β”‚
β”‚      β”‚                             β””β”€β”€β”€β”€β”€β”€β”€β”˜                     β”‚
β”‚      β–Ό                                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”                                                          β”‚
β”‚  β”‚ PAID │── reads: 300+extra                                      β”‚
β”‚  β”‚      β”‚   votes: 20+extra/day                                   β”‚
β”‚  β”‚      β”‚         600+extra/month                                 β”‚
β”‚  β””β”€β”€β”¬β”€β”€β”€β”˜                                                          β”‚
β”‚      β”‚                                                             β”‚
β”‚      β”‚ User signs out                                              β”‚
β”‚      β”‚                                                             β”‚
β”‚      β–Ό                                                             β”‚
β”‚  POST /v1/auth/unlink                                             β”‚
β”‚      β”‚                                                             β”‚
β”‚      β–Ό                                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                                    β”‚
β”‚  β”‚ ANONYMOUS β”‚ (usage counters preserved)                         β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                                    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Threat Model

What each layer defends against

Threat Layer 1 (Token) Layer 2 (Clerk) HMAC Signing Verdict Engine
Unauthenticated API access βœ“ β€” β€” β€”
Scraping without extension βœ“ β€” βœ“ β€”
Multi-account voting β€” βœ“ (partial) β€” βœ“
Vote weight manipulation β€” β€” β€” βœ“
Replay attacks β€” β€” βœ“ β€”
Coercive coordination β€” β€” β€” βœ“
Token farming (mass creation) βœ“ (IP limit) β€” β€” β€”
Credential theft (token extraction) β€” β€” βœ“ (raises bar) βœ“ (limits impact)

Known weaknesses

  1. HMAC secret extraction: The signing key is in the extension's JavaScript bundle. A determined attacker can extract it. The verdict engine's trust weighting is the real defense against automated abuse.

  2. IP-based rate limiting: VPNs and CGNAT defeat per-IP limits. The token creation limit (3/IP/day) is bypassable with IP rotation.

  3. Clerk dependency: If Clerk is down, new sign-ins fail. Existing linked tokens continue to work (the link is stored in Postgres). The static JWKS fallback handles Clerk JWKS outages for JWT verification.

  4. Token theft via malware: If malware extracts the token from chrome.storage.local, it can make authenticated requests. The HMAC signature is in the same bundle, so the malware can sign requests too. Browser extension storage is sandboxed; this requires host-level compromise.


Internal Auth

Website β†’ Backend

POST /v1/subscriptions is called by the website's webhook handler, not by the extension. It uses a shared secret (BACKEND_API_TOKEN) sent as X-Internal-Auth header.

func internalAuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
    expected := os.Getenv("BACKEND_API_TOKEN")
    if expected != "" {
        if r.Header.Get("X-Internal-Auth") != expected {
            return 401
        }
    }
    next(w, r)
}

Critical: If BACKEND_API_TOKEN is empty, the middleware is silently bypassed. Anyone can call /v1/subscriptions and create subscriptions. Both the backend and website .env files must set this to the same shared secret.

Why not use the HMAC signing for internal auth?

Internal auth uses a different mechanism because:

  1. The website doesn't have the HMAC signing key (that's extension-specific)
  2. The website and backend are both in the same trusted VPC
  3. A simple shared secret header is simpler and sufficient for service-to-service auth

Clone this wiki locally