-
Notifications
You must be signed in to change notification settings - Fork 0
Auth and Identity
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.
- Why Two-Layer Auth
- Layer 1: Anonymous Device Tokens
- Layer 2: Clerk Identity (JWT)
- Request Signing (HMAC)
- Quota Enforcement
- Token Lifecycle
- Threat Model
- Internal Auth
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 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).
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: {...} }
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.
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.
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')::dateLimitations 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_IPSenv 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.
| 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.
// 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).
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.
The @clerk/chrome-extension SDK was tried and removed. Reasons:
-
Firefox MV2 incompatibility: The SDK's
validateManifestcheck fails because Firefox MV2 doesn't includehost_permissionsinruntime.getManifest(). The bundledwebextension-polyfillreturnedundefinedfromstorage.get(). - CSP violations: Clerk's web workers are blocked by Firefox MV2 CSP.
- Bundle size: The SDK adds ~100 KB of JavaScript to the extension.
-
Single-use: Clerk is only needed at sign-in time (one
/auth/linkcall). 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.
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)
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.
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 = $1The 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.
// 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 tokenThis 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.
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.
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.
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.
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
// 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 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.
The HMAC secret can be extracted from the extension bundle (it's in the JavaScript source). A determined attacker can:
- Download the extension CRX
- Unzip it
- Find the secret in the bundled JavaScript
- 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.
To rotate the secret:
- Generate new secret:
openssl rand -hex 32 - Update
SLAZE_API_SECRETon the backend - Update
PLASMO_PUBLIC_SLAZE_API_SECRETin the extension - Rebuild and redeploy the extension
- 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.
| 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) |
| Tier | Daily Checks | Daily Votes | Monthly Votes |
|---|---|---|---|
| Anonymous | 50 | 0 | 0 |
| Email/Phone/Paid | 300 | 20 | 600 |
Active subscriptions add to the free baseline. See Pricing-and-Subscriptions.md for full details.
For vote routes, three limits are checked in order:
- Daily vote limit β prevents burst abuse within a day
- Monthly vote limit β total allowance ceiling
- Hourly vote limit β only when Hour Boost is active
For read routes:
- 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.
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.
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 β
β β
β 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 | 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) |
-
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.
-
IP-based rate limiting: VPNs and CGNAT defeat per-IP limits. The token creation limit (3/IP/day) is bypassable with IP rotation.
-
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.
-
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.
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.
Internal auth uses a different mechanism because:
- The website doesn't have the HMAC signing key (that's extension-specific)
- The website and backend are both in the same trusted VPC
- A simple shared secret header is simpler and sufficient for service-to-service auth