Skip to content

Gateway Security & Authentication Plan

Current State (Vulnerabilities)

The pfix.us gateway has zero authentication or rate limiting:

  1. Gateway β†’ API is fully open. POST /api/gateway/convert and /api/gateway/bulk accept any caller. Anyone who discovers api-pdf.theaccessible.org can invoke conversions directly, bypassing the gateway entirely.
  2. No credit deduction. Gateway conversions call recordCost() with userId: undefined β€” costs are tracked in the ledger but never charged to a user’s credit balance.
  3. No rate limiting. No IP-based, session-based, or token-based throttling exists on any gateway path. A single client can trigger unlimited parallel conversions.
  4. No tenant context. The gateway Worker sends no X-Tenant-ID header, so all gateway conversions are attributed to the default tenant.
  5. Cache is free to populate. Any URL can be submitted, filling R2 storage with cached conversions at no cost to the requester.

Design Principle: No Anonymous Access

All gateway conversions require an authenticated user. There is no free/anonymous tier. Unauthenticated requests to pfix.us are redirected to a sign-in page. This eliminates the entire class of anonymous abuse vectors and ensures every conversion is tied to a paying account with credits.

Cached content is public. Once a document has been converted and cached in R2, any subsequent request for the same URL + flags combination is served directly from cache without authentication. Auth is only required to trigger a new conversion. This means the first authenticated user who converts a document effectively pays for all future viewers β€” a reasonable tradeoff since cache hits cost nothing.


Architecture Overview

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ End User (Browser) β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚
Unauthenticatedβ”‚ β”‚ Authenticated
β†’ sign-in wall β”‚ β”‚ (JWT cookie or API key)
β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Gateway Worker (pfix.us) β”‚
β”‚ β”‚
β”‚ 1. Parse URL + flags β”‚
β”‚ 2. Check R2 cache β†’ serve if hit β”‚
β”‚ 3. Require auth (cookie or API key) β”‚
β”‚ 4. Rate limit (per-user KV counter) β”‚
β”‚ 5. POST to API with auth context β”‚
β”‚ 6. Serve interstitial β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
X-Gateway-Secret header
X-Gateway-User / X-Gateway-Tier headers
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ API Worker (api-pdf.theaccessible.org) β”‚
β”‚ β”‚
β”‚ 1. Verify X-Gateway-Secret β”‚
β”‚ 2. Check credit balance β”‚
β”‚ 3. Enforce spend limits β”‚
β”‚ 4. Run conversion pipeline β”‚
β”‚ 5. Deduct credits post-conversion β”‚
β”‚ 6. Record cost in ledger β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

1. Gateway-to-API Shared Secret

Problem: The /api/gateway/* endpoints are public. Anyone can call them directly.

Solution: A static shared secret verified on every gateway-originated request.

Implementation

Gateway Worker (workers/gateway/src/index.ts):

// Add to GatewayEnv:
GATEWAY_API_SECRET: string;
// When calling the API:
const apiResponse = await fetch(`${c.env.API_BASE_URL}/api/gateway/convert`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Gateway-Secret': c.env.GATEWAY_API_SECRET,
},
body: JSON.stringify({ url: sourceUrl, flags, cacheKey }),
});

API Worker (workers/api/src/routes/gateway.ts):

// New middleware applied to all gateway routes:
function requireGatewaySecret(c, next) {
const secret = c.req.header('X-Gateway-Secret');
if (!secret || secret !== c.env.GATEWAY_API_SECRET) {
return error(c, 'UNAUTHORIZED', 'Invalid gateway secret', 401);
}
return next();
}
gateway.use('*', requireGatewaySecret);

Secret provisioning:

Terminal window
# Generate a 64-char hex secret:
openssl rand -hex 32
# Set on both workers:
echo "SECRET" | npx wrangler secret put GATEWAY_API_SECRET --env production # gateway worker
echo "SECRET" | npx wrangler secret put GATEWAY_API_SECRET --env production # api worker
# Also set in docker .env for Node servers

Files to modify:

  • workers/gateway/src/index.ts β€” add header to API calls
  • workers/gateway/wrangler.toml β€” declare the secret binding
  • workers/api/src/routes/gateway.ts β€” add verification middleware
  • workers/api/src/types/env.ts β€” add GATEWAY_API_SECRET to Env

2. User Authentication (Required)

Every gateway conversion requires authentication. Two methods are supported:

MethodUse CaseRate LimitCredit Cost
JWT CookieBrowser users logged in via pdf.anglin.com50 conversions/hour per userCredits deducted
API KeyProgrammatic access, browser extensions, bulkPer-key configurable (default 100/hour)Credits deducted

Unauthenticated requests receive a sign-in page instead of the interstitial.

2a. Unauthenticated Request Flow

When a user visits pfix.us/example.com/doc.pdf without auth:

  1. Gateway Worker checks for __pfix_session cookie or Authorization: Bearer pdfx_... header
  2. Neither found β†’ do not start conversion
  3. Serve a branded sign-in page:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ β”‚
β”‚ πŸ”’ Sign in to view this document β”‚
β”‚ β”‚
β”‚ TheAccessible.org converts PDFs to β”‚
β”‚ WCAG-compliant accessible HTML. β”‚
β”‚ β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ Sign In β”‚ ← links to β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ pdf.anglin β”‚
β”‚ .com/login β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ Create Account β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚
β”‚ Already have an API key? β”‚
β”‚ Use it with the browser extension β”‚
β”‚ or pass it as a Bearer token. β”‚
β”‚ β”‚
β”‚ ───────────────────────── β”‚
β”‚ Can't wait? β”‚
β”‚ View original PDF β†’ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The sign-in link includes a redirect parameter: https://pdf.anglin.com/login?redirect=https://pfix.us/{original-path} so the user returns to the gateway URL after authenticating.

Logged-in users from the main pdf.anglin.com app can use the gateway with their existing account and credits.

How it works:

  1. User logs in at pdf.anglin.com (Supabase Auth β†’ JWT)
  2. A cross-domain auth flow sets a __pfix_session cookie on pfix.us:
    • Frontend calls POST /api/gateway/auth/session with the Supabase JWT
    • API validates the JWT and returns a signed session token (short-lived, 1 hour)
    • Gateway Worker stores this as an HttpOnly cookie on pfix.us
  3. On subsequent gateway requests, the Gateway Worker reads the cookie and passes X-Gateway-User: {userId} and X-Gateway-Tier: authenticated to the API
  4. API checks credits, enforces spend limits, deducts after conversion

Session token format: A JWT signed with a separate GATEWAY_SESSION_SECRET, containing:

{
"sub": "user-uuid",
"email": "user@example.com",
"tenantId": "default",
"iat": 1709740800,
"exp": 1709744400
}

New endpoints:

  • POST /api/gateway/auth/session β€” exchange Supabase JWT for gateway session token
  • DELETE /api/gateway/auth/session β€” revoke (clear cookie)
  • GET /api/gateway/auth/status β€” check if user has a valid session (returns user email + credit balance)

Files to create/modify:

  • NEW workers/api/src/routes/gateway-auth.ts β€” route file for session management
  • workers/gateway/src/index.ts β€” cookie reading logic, header injection, sign-in page rendering
  • NEW workers/gateway/src/sign-in-page.ts β€” branded sign-in page HTML

2c. API Key Access (Programmatic)

For integrations, browser extensions, and the bulk endpoint.

Key format: pdfx_live_{base62_random_32chars} (e.g., pdfx_live_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6)

Database schema (Supabase migration):

CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL, -- user-provided label
key_prefix TEXT NOT NULL, -- first 8 chars for display: "pdfx_liv"
key_hash TEXT NOT NULL UNIQUE, -- SHA-256 of full key
scopes TEXT[] NOT NULL DEFAULT '{gateway}', -- 'gateway', 'bulk', 'convert'
rate_limit_per_hour INT DEFAULT 100,
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ, -- null = no expiry
revoked_at TIMESTAMPTZ, -- null = active
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash) WHERE revoked_at IS NULL;
CREATE INDEX idx_api_keys_user ON api_keys(user_id);

Key lifecycle endpoints (protected by requireAuth β€” user must be logged in to manage keys):

  • POST /api/keys β€” generate a new key (returns the full key ONCE; only the hash is stored)
  • GET /api/keys β€” list user’s keys (shows prefix + name + last_used, never the full key)
  • DELETE /api/keys/:id β€” revoke a key
  • PATCH /api/keys/:id β€” update name, rate limit, expiry

Validation middleware (requireApiKey):

async function requireApiKey(c, next) {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer pdfx_')) return error(c, 'UNAUTHORIZED', 'API key required', 401);
const key = authHeader.slice(7);
const keyHash = await sha256(key);
// Lookup in Supabase (cached in KV for 5 min)
const apiKey = await lookupApiKey(c.env, keyHash);
if (!apiKey || apiKey.revoked_at || (apiKey.expires_at && new Date(apiKey.expires_at) < new Date())) {
return error(c, 'UNAUTHORIZED', 'Invalid or expired API key', 401);
}
// Check key-specific rate limit
const rateLimitKey = `ratelimit:apikey:${apiKey.id}:${hourBucket()}`;
const count = await incrementKV(c.env.KV_RATE_LIMIT, rateLimitKey, 3600);
if (count > apiKey.rate_limit_per_hour) {
return error(c, 'RATE_LIMITED', 'API key rate limit exceeded', 429);
}
// Touch last_used_at (fire-and-forget)
updateLastUsed(c.env, apiKey.id);
c.set('userId', apiKey.user_id);
c.set('email', apiKey.email);
c.set('tenantId', apiKey.tenant_id);
return next();
}

How API keys reach the gateway:

For browser-extension or programmatic use of pfix.us:

GET https://pfix.us/example.com/doc.pdf
Authorization: Bearer pdfx_live_a1B2c3D4...

The Gateway Worker reads the Authorization header and forwards it to the API as X-Gateway-Api-Key: pdfx_live_.... The API’s gateway middleware validates it using requireApiKey logic.

For the bulk endpoint:

POST https://api-pdf.theaccessible.org/api/gateway/bulk
Authorization: Bearer pdfx_live_a1B2c3D4...
Content-Type: application/json
{"urls": ["https://example.com/a.pdf", "https://example.com/b.pdf"]}

Files to create/modify:

  • NEW workers/api/src/routes/api-keys.ts β€” CRUD endpoints for key management
  • NEW workers/api/src/middleware/api-key-auth.ts β€” requireApiKey middleware
  • NEW workers/api/src/services/api-keys.ts β€” key generation, hashing, lookup, caching
  • workers/gateway/src/index.ts β€” forward Authorization header to API
  • Supabase migration for api_keys table

3. Credit Integration for Gateway

Problem: Gateway conversions incur real AI cost (~$0.01-0.50 per document) but charge no one.

Solution: Every gateway conversion checks and deducts credits, identical to the upload pipeline.

Credit Check and Deduction

Modification to runGatewayConversion() in workers/api/src/routes/gateway.ts:

The gateway conversion function receives userId from the gateway middleware (resolved from JWT cookie or API key). Since there is no anonymous tier, userId is always present.

// After PDF fetch and classification, before cascade starts:
const pageCount = classification.totalPages;
const creditCheck = await checkCredits(env, userId, pageCount);
if (!creditCheck.hasCredits) {
throw new GatewayAuthError('INSUFFICIENT_CREDITS',
`Need ${pageCount} credits but have ${creditCheck.balance}`);
}
const spendCheck = await checkSpendLimits(env, userId, pageCount);
if (!spendCheck.allowed) {
throw new GatewayAuthError('SPEND_LIMIT_EXCEEDED', spendCheck.reason);
}
// After successful conversion (final assembly done):
const actualPages = completedPages.size;
await deductCredits(env, userId, actualPages,
`Gateway conversion: ${sourceUrl} (${actualPages} pages)`);

Error Handling

A new GatewayAuthError class distinguishes credit/auth failures from conversion failures. The error type is stored in the job status so the interstitial can render the appropriate UI:

class GatewayAuthError extends Error {
constructor(public code: 'INSUFFICIENT_CREDITS' | 'SPEND_LIMIT_EXCEEDED' | 'UNAUTHORIZED', message: string) {
super(message);
this.name = 'GatewayAuthError';
}
}

In the interstitial:

  • INSUFFICIENT_CREDITS β†’ β€œYou need more credits to convert this document. [Purchase Credits]”
  • SPEND_LIMIT_EXCEEDED β†’ β€œYou’ve reached your spend limit. [Manage Limits]β€œ

Cost Attribution

Update recordCost() calls to include the authenticated user:

recordCost(env, {
userId,
operationType: 'gateway-conversion',
metadata: {
tier: authMethod, // 'authenticated' | 'api-key'
sourceUrl,
totalPages,
// ... existing fields
},
});

Files to modify:

  • workers/api/src/routes/gateway.ts β€” add credit check/deduct flow, accept userId parameter
  • workers/api/src/services/credits.ts β€” (already exists, just import and use)

4. Rate Limiting

Implementation: KV-Based Sliding Window

All rate limiting uses Cloudflare KV with TTL-based expiration. This is simple and works across all Workers instances (KV is globally distributed).

Rate limit helper (new utility):

workers/gateway/src/rate-limiter.ts
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number; // Unix timestamp
}
async function checkRateLimit(
kv: KVNamespace,
key: string,
limit: number,
windowSeconds: number,
): Promise<RateLimitResult> {
const bucket = Math.floor(Date.now() / 1000 / windowSeconds);
const kvKey = `${key}:${bucket}`;
const current = parseInt(await kv.get(kvKey) || '0', 10);
if (current >= limit) {
return {
allowed: false,
remaining: 0,
resetAt: (bucket + 1) * windowSeconds,
};
}
// Increment (non-atomic, but acceptable for rate limiting)
await kv.put(kvKey, String(current + 1), { expirationTtl: windowSeconds * 2 });
return {
allowed: true,
remaining: limit - current - 1,
resetAt: (bucket + 1) * windowSeconds,
};
}

Rate Limits by Auth Method

MethodScopeLimitWindowKV Key Pattern
JWT CookieUser50 conversions1 hourrl:user:{userId}:{hourBucket}
JWT CookieUser500 conversions24 hoursrl:user-day:{userId}:{dayBucket}
API KeyKeyConfigurable (default 100)1 hourrl:key:{keyId}:{hourBucket}

Rate limiting also serves as a safety net against compromised accounts or runaway scripts.

Response headers (set on all gateway responses):

X-RateLimit-Limit: 50
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1709744400

429 response: When rate limited, the interstitial shows a friendly message with the reset time. For API key users, a JSON 429 response is returned.

Rate Limiting Location

Rate limiting runs in the Gateway Worker (before calling the API), because:

  • It’s the public entry point β€” stops abuse before any AI cost is incurred
  • KV reads are fast (~10ms) and free on the Workers free plan
  • The API Worker only receives pre-validated requests

Files to create/modify:

  • NEW workers/gateway/src/rate-limiter.ts
  • workers/gateway/src/index.ts β€” apply rate limiting before API call
  • workers/gateway/wrangler.toml β€” bind KV_RATE_LIMIT namespace to gateway

5. Tenant Propagation

Problem: Gateway conversions all fall back to the default tenant.

Solution: The Gateway Worker resolves tenant from the request context and passes it through.

Resolution Logic (in Gateway Worker)

  1. Custom domain: If the gateway is white-labeled (e.g., accessible.company.com), resolve tenant from the hostname via a KV lookup (tenant-domain:{hostname} β†’ tenantId)
  2. API key: If the request has an API key, the key’s tenant_id is used
  3. Session cookie: The gateway session JWT includes tenantId
  4. Default: Fall back to default

Header passed to API: X-Gateway-Tenant: {tenantId}

API Worker reads this header in the gateway middleware and sets c.set('tenantId', ...).

Files to modify:

  • workers/gateway/src/index.ts β€” tenant resolution + header injection
  • workers/api/src/routes/gateway.ts β€” read tenant header in middleware

6. Interstitial Auth UI

The interstitial page shows the user’s auth state and credit info.

Authenticated User View (Normal)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ [spinner] Preparing your accessible β”‚
β”‚ document β”‚
β”‚ β”‚
β”‚ Converting to WCAG-compliant HTML β”‚
β”‚ [standard] β”‚
β”‚ β”‚
β”‚ Signed in as larry@anglin.com β”‚
β”‚ Credits: 1,247 remaining β”‚
β”‚ Converting page 5 of 47 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Insufficient Credits View

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ ⚠ Insufficient credits β”‚
β”‚ β”‚
β”‚ This document has 47 pages. β”‚
β”‚ You have 12 credits remaining. β”‚
β”‚ β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ Purchase Credits β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚ β”‚
β”‚ Can't wait? β”‚
β”‚ View original PDF β†’ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Rate Limited View

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ ⏱ Rate limit reached β”‚
β”‚ β”‚
β”‚ You've hit the hourly conversion β”‚
β”‚ limit. Try again in 23 minutes. β”‚
β”‚ β”‚
β”‚ Can't wait? β”‚
β”‚ View original PDF β†’ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The status endpoint (GET /api/gateway/status/:conversionId) is extended to return:

{
"data": {
"status": "converting",
"progress": 45,
"phase": "Converting page 5 of 47",
"userEmail": "larry@anglin.com",
"creditBalance": 1247
}
}

Files to modify:

  • workers/gateway/src/interstitial.ts β€” add auth state display, credit balance, error CTAs
  • workers/api/src/routes/gateway.ts β€” return user info + credit balance in status response

7. Abuse Prevention

URL Validation

Not all URLs should be convertible. Block:

  • Private/internal IPs (10.x, 192.168.x, 127.x, 169.254.x, ::1, fc00::)
  • Localhost and link-local addresses
  • Non-PDF URLs (already enforced via content-type check)
  • URLs longer than 2048 characters
  • Known malicious domains (optional blocklist)
workers/gateway/src/url-validator.ts
function isAllowedUrl(url: string): { allowed: boolean; reason?: string } {
const parsed = new URL(url);
// Block private IPs
if (isPrivateIP(parsed.hostname)) return { allowed: false, reason: 'Private IP addresses not allowed' };
// Block non-HTTP(S)
if (!['http:', 'https:'].includes(parsed.protocol)) return { allowed: false, reason: 'Only HTTP/HTTPS allowed' };
// Block overly long URLs
if (url.length > 2048) return { allowed: false, reason: 'URL too long' };
return { allowed: true };
}

SSRF Prevention

The safeFetch() function in workers/api/src/services/url-fetcher.ts should validate the resolved IP (not just the hostname) to prevent DNS rebinding:

  • After DNS resolution, before connecting, verify the IP is not private
  • Use fetch() with Cloudflare’s built-in protections where available

Logging for Abuse Detection

All gateway requests should log:

{
"event": "gateway_request",
"sourceUrl": "https://example.com/doc.pdf",
"userId": "user-uuid",
"authMethod": "jwt-cookie",
"rateLimitRemaining": 47,
"cached": false,
"timestamp": "2026-03-06T12:00:00Z"
}

Feed these into Loki. Alert on:

  • Single user >100 conversions/day (possible script abuse)
  • Single API key >1000 conversions/day
  • Repeated 429s from the same user (brute-force attempt)
  • Credit check failures (may indicate stolen credentials being tested)

8. Migration Plan (Implementation Order)

Phase 1: Gateway Secret (Day 1) β€” Critical

Close the open API endpoint. No user-facing changes.

  1. Generate shared secret
  2. Add GATEWAY_API_SECRET to both workers
  3. Add verification middleware to API gateway routes
  4. Add header injection to Gateway Worker
  5. Deploy both workers
  6. Verify: direct API calls without secret return 401

Phase 2: Authenticated Gateway Sessions (Day 1-3) β€” Critical

Require auth for all conversions.

  1. Create gateway-auth.ts routes (session exchange, status, revoke)
  2. Add GATEWAY_SESSION_SECRET to both workers
  3. Add cookie reading + JWT validation to Gateway Worker
  4. Create sign-in-page.ts for unauthenticated users
  5. Pass X-Gateway-User + X-Gateway-Tier: authenticated headers
  6. Add sign-in redirect flow (pdf.anglin.com/login?redirect=pfix.us/...)
  7. Deploy and test

Phase 3: Credit Integration (Day 3-4) β€” Critical

Charge for conversions.

  1. Import checkCredits, checkSpendLimits, deductCredits into gateway route
  2. Add credit check before conversion starts
  3. Add credit deduction after successful conversion
  4. Add GatewayAuthError class for credit/spend errors
  5. Update interstitial with credit balance display and insufficient-credits UI
  6. Update status endpoint to return userEmail and creditBalance
  7. Deploy and test

Phase 4: Rate Limiting (Day 4-5)

Prevent excessive usage even from authenticated users.

  1. Create rate-limiter.ts utility
  2. Bind KV_RATE_LIMIT to Gateway Worker
  3. Add per-user rate limit check before API call in Gateway Worker
  4. Add X-RateLimit-* response headers
  5. Add 429 handling to interstitial
  6. Deploy and test

Phase 5: API Key System (Day 5-7)

Enable programmatic access.

  1. Create Supabase migration for api_keys table
  2. Create api-keys.ts service (generate, hash, lookup, cache)
  3. Create api-key-auth.ts middleware
  4. Create api-keys.ts routes (CRUD)
  5. Add API key forwarding to Gateway Worker
  6. Apply requireApiKey to /api/gateway/bulk
  7. Add API key management UI to pdf.anglin.com settings
  8. Deploy and test

Phase 6: Tenant Propagation (Day 7-8)

Attribute gateway usage to correct tenants.

  1. Add tenant resolution logic to Gateway Worker
  2. Pass X-Gateway-Tenant header
  3. Read tenant header in API gateway middleware
  4. Test with white-labeled domains

Phase 7: Abuse Prevention Hardening (Day 8-10)

Polish security posture.

  1. Add URL validation (private IP blocking, SSRF prevention)
  2. Add structured logging for all gateway requests
  3. Set up Grafana alerts for abuse patterns
  4. Security audit of the complete auth flow

9. Environment Variables Summary

VariableWorkerPurpose
GATEWAY_API_SECRETBothShared secret for gateway β†’ API auth
GATEWAY_SESSION_SECRETBothJWT signing key for gateway session cookies
KV_RATE_LIMITGatewayKV namespace for rate limit counters

10. Database Migrations Required

-- Phase 5: API Keys
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
scopes TEXT[] NOT NULL DEFAULT '{gateway}',
rate_limit_per_hour INT DEFAULT 100,
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash) WHERE revoked_at IS NULL;
CREATE INDEX idx_api_keys_user ON api_keys(user_id);
-- Optional: gateway usage analytics
CREATE TABLE gateway_usage (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id),
api_key_id UUID REFERENCES api_keys(id),
source_url TEXT NOT NULL,
auth_method TEXT NOT NULL, -- 'jwt-cookie' | 'api-key'
pages_converted INT,
credits_deducted INT,
cost_usd NUMERIC(10,6),
cached BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_gateway_usage_user ON gateway_usage(user_id, created_at);
CREATE INDEX idx_gateway_usage_key ON gateway_usage(api_key_id, created_at);

11. Testing Checklist

  • Direct API call to /api/gateway/convert without secret β†’ 401
  • Gateway Worker call with secret β†’ 200/202
  • Unauthenticated browser request β†’ sign-in page (not interstitial)
  • Cached URL without auth β†’ served from R2 (cache hits are public)
  • Uncached URL without auth β†’ sign-in page
  • JWT cookie auth β†’ conversion starts, credits checked
  • Authenticated: credit deducted after conversion
  • Authenticated: insufficient credits β†’ 402 with β€œPurchase credits” CTA
  • Authenticated: spend limit exceeded β†’ 429 with explanation
  • Authenticated: 51st conversion in 1 hour β†’ 429
  • API key: valid key β†’ conversion succeeds, credits deducted
  • API key: revoked key β†’ 401
  • API key: expired key β†’ 401
  • API key: rate limit exceeded β†’ 429
  • Bulk endpoint without API key β†’ 401
  • Private IP in source URL β†’ rejected
  • Tenant resolved correctly from custom domain
  • Grafana alerts fire on abuse patterns
  • Sign-in redirect round-trip works (pdf.anglin.com β†’ pfix.us)
  • Session cookie expires after 1 hour, user redirected to sign in again
  • Credit balance shown in interstitial during conversion

12. Files Summary

FileChange
NEW workers/gateway/src/sign-in-page.tsBranded sign-in page for unauthenticated users
NEW workers/gateway/src/rate-limiter.tsKV-based rate limit utility
NEW workers/gateway/src/url-validator.tsURL validation (private IP blocking, etc.)
NEW workers/api/src/routes/gateway-auth.tsSession exchange, status, revoke endpoints
NEW workers/api/src/routes/api-keys.tsAPI key CRUD endpoints
NEW workers/api/src/middleware/api-key-auth.tsrequireApiKey middleware
NEW workers/api/src/services/api-keys.tsKey generation, hashing, lookup, KV caching
workers/gateway/src/index.tsAuth check, cookie reading, header injection, sign-in redirect
workers/gateway/src/interstitial.tsAuth state display, credit balance, error CTAs
workers/gateway/wrangler.tomlAdd GATEWAY_API_SECRET, KV_RATE_LIMIT bindings
workers/api/src/routes/gateway.tsGateway secret middleware, credit check/deduct, userId param
workers/api/src/types/env.tsAdd GATEWAY_API_SECRET, GATEWAY_SESSION_SECRET to Env
Supabase migrationapi_keys table, gateway_usage table