HOW WE PASSED GOOGLE CASA TIER 2 ON A CLOUDFLARE WORKER
8 security controls. One Worker. Open-sourced the checklist.
Problem: Your OAuth app requests Gmail scopes. Google requires CASA Tier 2 .. a lab-verified security assessment mapped to OWASP ASVS Level 2. No pass, no production access.
Solution: inbox.dog passed CASA on a Cloudflare Worker with 3 runtime dependencies. We extracted every security control into an open-source checklist. Fork it, check the boxes, submit for audit.
Client → Worker → Google OAuth
↓
┌───────────────────────────┐
│ 7 security headers │
│ TRACE/TRACK blocking │
│ Error sanitization │
│ Timing-safe comparisons │
│ AES-256-GCM encryption │
│ Redirect URI allowlist │
│ KV rate limiting │
│ Webhook verification │
└───────────────────────────┘
↓
KV (encrypted tokens, TTLs)
↓
CASA Tier 2 ✓WHAT CASA TIER 2 IS
Casa .. Cloud Application Security Assessment. Google's App Defense Alliance requires it for any OAuth app accessing sensitive Gmail scopes.
TIER 1
Self-assessment questionnaire. Low-risk scopes.
TIER 2
Lab-verified scan + review. Gmail read/send scopes. OWASP ASVS Level 2.
TIER 3
Full penetration test. Highest-risk scopes.
Tier 2 is the sweet spot for Gmail OAuth. It requires a third-party lab to verify your security posture against OWASP ASVS controls. No rubber stamps .. they run OWASP ZAP, review your architecture, and verify each control.
THE 8 CONTROLS
1. SECURITY HEADERS
Seven headers on every response. One middleware.
// worker/src/index.ts
// Security headers on all responses (CASA Z-2, Z-3, Z-5)
app.use('*', async (c, next) => {
await next();
c.header('X-Content-Type-Options', 'nosniff');
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
c.header('X-Frame-Options', 'DENY');
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
c.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
c.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
});
// Block TRACE/TRACK methods (CASA Z-1 Proxy Disclosure)
app.use('*', async (c, next) => {
if (c.req.method === 'TRACE' || c.req.method === 'TRACK') {
c.status(405);
return c.text('Method Not Allowed');
}
return next();
});X-Content-Type-Options: nosniff .. prevents MIME-type sniffing. Hsts .. forces HTTPS for a year. X-Frame-Options: DENY .. blocks clickjacking. Csp .. default-src 'none' on API responses because this Worker serves JSON, not HTML. Permissions-Policy .. disables geolocation, microphone, camera.
TRACE/TRACK blocking prevents HTTP method-based proxy disclosure attacks. Cloudflare blocks TRACE at the edge already, but the auditor's ZAP scan checks for it at the application level.
2. ERROR SANITIZATION
No stack traces. No internal details. Structured errors for machines and humans.
// worker/src/index.ts
// No stack traces leaked — global handler returns generic message
app.onError((err, c) => {
console.error('Unhandled error:',
err instanceof Error ? err.message : 'Unknown error'
);
return c.json({
error: {
code: 'INTERNAL_ERROR',
message: 'Internal server error',
action: 'Retry the request',
docs: 'https://inbox.dog/docs/errors'
}
}, 500);
});Every error response returns code, message, action, and docs. The client knows what went wrong and what to do.
The attacker learns nothing about your internals.
3. TIMING-SAFE SECRET COMPARISON
HMAC-then-compare. Not string equality. Web Crypto only .. no Node.
// worker/src/utils.ts
// HMAC-then-compare prevents timing oracle attacks
export async function timingSafeEqual(
a: string,
b: string
): Promise<boolean> {
const encoder = new TextEncoder();
const aBuf = encoder.encode(a);
const bBuf = encoder.encode(b);
if (aBuf.byteLength !== bBuf.byteLength) {
// Compare against self to burn constant time, then return false
const dummy = new Uint8Array(aBuf.byteLength);
const aKey = await crypto.subtle.importKey(
'raw', aBuf,
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
await crypto.subtle.sign('HMAC', aKey, dummy);
return false;
}
const key = await crypto.subtle.importKey(
'raw', encoder.encode('timing-safe-compare'),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const aMac = new Uint8Array(
await crypto.subtle.sign('HMAC', key, aBuf)
);
const bMac = new Uint8Array(
await crypto.subtle.sign('HMAC', key, bBuf)
);
let result = 0;
for (let i = 0; i < aMac.length; i++) {
result |= aMac[i]! ^ bMac[i]!;
}
return result === 0;
}Cloudflare Workers don't have Node's crypto.timingSafeEqual.
The workaround: HMAC both inputs with the same key, then compare the MACs byte-by-byte.
The HMAC operation takes constant time regardless of input content. On length mismatch,
we still burn time with a dummy HMAC to avoid leaking length information.
4. AES-256-GCM TOKEN ENCRYPTION
Tokens encrypted before KV storage. PBKDF2 key derivation. Random IV per operation.
// worker/src/crypto.ts
// AES-256-GCM with PBKDF2 key derivation (100k iterations)
// Output: base64( 12-byte IV || ciphertext || 16-byte GCM auth tag )
const PBKDF2_ITERATIONS = 100_000;
const IV_BYTES = 12;
async function deriveKey(secret: string): Promise<CryptoKey> {
const secretBytes = new TextEncoder().encode(secret);
const salt = await crypto.subtle.digest('SHA-256', secretBytes);
const baseKey = await crypto.subtle.importKey(
'raw', secretBytes, 'PBKDF2', false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
export async function encrypt(
plaintext: string, secret: string
): Promise<string> {
const key = await deriveKey(secret);
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, key,
new TextEncoder().encode(plaintext)
);
// Prepend IV so we can extract it during decryption
const combined = new Uint8Array(IV_BYTES + encrypted.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(encrypted), IV_BYTES);
return btoa(String.fromCharCode(...combined));
}Why GCM: Authenticated encryption .. if someone tampers with the ciphertext,
decryption fails. No silent corruption. Why PBKDF2: Turns your deployment secret into a proper 256-bit key.
100k iterations to resist brute force. Why random IV: 12-byte IV via crypto.getRandomValues() .. GCM requires a unique IV per encryption. All Web Crypto, no Node modules.
5. REDIRECT URI ALLOWLIST
Per-client allowlist. URL normalization. No open redirects.
// worker/src/routes/oauth.ts
// Per-client redirect_uri allowlist validation
const apiKey = yield* kv.getApiKey(clientId);
if (apiKey.redirectUris && apiKey.redirectUris.length > 0) {
const normalizedRedirect =
new URL(redirectUri).origin + new URL(redirectUri).pathname;
const allowed = apiKey.redirectUris.some((uri) => {
const normalizedAllowed =
new URL(uri).origin + new URL(uri).pathname;
return normalizedRedirect === normalizedAllowed;
});
if (!allowed) {
return yield* Effect.fail(
new ValidationError({
field: 'redirect_uri',
message: 'redirect_uri not in allowlist. '
+ 'Register URIs when creating your API key.'
})
);
}
}Redirect URIs are registered at key creation and validated on every OAuth flow.
Both the incoming URI and the allowlist entries are normalized to origin + pathname before comparison.
This prevents bypass via query params, fragments, or trailing slashes.
HTTPS required (localhost exempt for development).
6. RATE LIMITING
KV-based sliding window. No external dependencies. TTLs do the cleanup.
// worker/src/index.ts
// KV-based sliding window rate limiting
app.use('/oauth/token', async (c, next) => {
if (c.req.method !== 'POST') return next();
const ip = c.req.header('cf-connecting-ip') ?? 'unknown';
const key = `ratelimit:token:${ip}`;
const current = parseInt(
await c.env.KV.get(key) ?? '0', 10
);
if (current >= 20) {
return c.json({
error: {
code: 'RATE_LIMITED',
message: 'Too many token requests. Max 20 per minute.',
action: 'Wait before retrying',
docs: 'https://inbox.dog/docs/errors'
}
}, 429);
}
// 60-second TTL = auto-expiring window
await c.env.KV.put(key, String(current + 1), { expirationTtl: 60 });
return next();
});Two rate limits: key creation (5/min) and token exchange (20/min). KV's built-in TTL handles expiration .. no cron jobs, no cleanup logic. The counter auto-expires after 60 seconds. Simple, but effective enough for CASA.
7. WEBHOOK VERIFICATION + IDEMPOTENCY
HMAC-SHA256 signatures. Timestamp validation. A bounded duplicate check.
// worker/src/routes/webhooks.ts
// HMAC-SHA256 signature verification + replay protection
async function verifyStripeSignature(
payload: string, signature: string, secret: string
): Promise<{ valid: boolean; reason?: string }> {
const parts = signature.split(',');
const timestamp = parts.find(p => p.startsWith('t='))?.slice(2);
const sig = parts.find(p => p.startsWith('v1='))?.slice(3);
if (!timestamp || !sig) {
return { valid: false, reason: 'Malformed signature header' };
}
// Reject replays outside 5-minute window
const eventTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - eventTime) > 300) {
return { valid: false, reason: 'Timestamp outside tolerance' };
}
// HMAC-SHA256 the timestamp.payload
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const expected = await crypto.subtle.sign(
'HMAC', key,
new TextEncoder().encode(`${timestamp}.${payload}`)
);
// Timing-safe comparison of the HMAC output
// ... (same HMAC-then-compare pattern from timingSafeEqual)
return diff === 0 ? { valid: true } : { valid: false };
}
// Idempotency via KV with 24h TTL
const idempotencyKey = `webhook_processed:${session.id}`;
const alreadyProcessed = await c.env.KV.get(idempotencyKey);
if (alreadyProcessed) return c.json({ received: true });
// ... process webhook ...
await c.env.KV.put(idempotencyKey, '1', { expirationTtl: 86400 });Three checks: signature verification (did the payload authenticate?), timestamp validation (is it recent?), and an event key in KV with a 24-hour TTL (have we seen this key recently?). KV is eventually consistent, so this reduces duplicate work within the window but is not a transactional process-once guarantee. The timing-safe signature comparison uses the same HMAC-then-compare pattern from control #3.
8. SHORT-LIVED, SINGLE-USE TOKENS
OAuth state: 10 min TTL. Auth codes: 5 min TTL. Both deleted after use.
OAuth state → KV TTL 600s → deleted on callback Auth code → KV TTL 300s → deleted on exchange Rate limits → KV TTL 60s → auto-expire Idempotency → KV TTL 86400s → auto-expire Tokens → AES-256-GCM → encrypted at rest
KV TTLs are the enforcement mechanism. Even if deletion fails, the token expires. Belt and suspenders.
The Audit Process
- 1. Self-assessment. Complete Google's SAQ (Self-Assessment Questionnaire). Map your controls to OWASP ASVS.
- 2. OWASP ZAP scan. The lab runs an automated scan against your endpoints. The security headers and error sanitization matter here .. ZAP flags missing headers and leaked stack traces.
- 3. Lab review. A security assessor reviews your architecture, data flow, and controls. We used TAC Security. Cost: ~$550. Turnaround: ~2 weeks.
- 4. Remediation (if needed). Fix findings, resubmit. We passed on first submission.
- 5. Letter of Assessment. Lab submits to Google. Google approves your OAuth consent screen. You're in production.
The Stack
Runtime
- • Cloudflare Worker .. edge compute, no servers
- • KV .. state, tokens, rate limits, TTLs
- • 3 dependencies: hono, effect, @effect/schema
- • Web Crypto API .. AES-256-GCM, PBKDF2, HMAC
Security Posture
- • No database .. KV with TTLs, nothing to breach
- • No filesystem .. Workers are stateless
- • No Node.js .. Web Crypto only
- • No secrets in code ..
wrangler secret put
The Open-Source Checklist
We extracted every CASA control into a checkable markdown file: CASA-CHECKLIST.md.
- ✓ V1: Architecture & Design .. environment isolation, scoped secrets, minimal deps
- ✓ V2: Authentication .. timing-safe comparison, OAuth state, redirect allowlist
- ✓ V3: Session Management .. short-lived codes, single-use tokens, no-store headers
- ✓ V5: Input Validation .. Effect Schema on all reads, URL parsing, structured errors
- ✓ V6: Cryptography .. AES-256-GCM, PBKDF2, random IVs, crypto IDs
- ✓ V7: Error Handling .. no stack traces, typed errors, generic auth failures
- ✓ V8: Data Protection .. deletion endpoints, KV TTLs, no tokens in URLs
- ✓ V9: Communications .. 7 security headers, TRACE blocking, scoped CORS
- ✓ V10: Webhook Security .. HMAC verification, timestamp validation, idempotency
- ✓ V13: API Security .. rate limiting, proper status codes, machine-readable errors
Fork the repo. Check the boxes as you implement each control. Use it as your audit preparation document.
MCP Server
inbox.dog also ships an MCP server for AI agents that need to read Gmail. If you're building agentic workflows that process email, the OAuth flow + MCP server handles auth and message retrieval so the agent doesn't need direct credential access.
Details at inbox.dog.
Files That Matter
| File | Controls |
|---|---|
| worker/src/index.ts | Headers, CORS, rate limiting, TRACE |
| worker/src/utils.ts | timingSafeEqual, generateId, generateSecret |
| worker/src/crypto.ts | AES-256-GCM encrypt/decrypt |
| worker/src/routes/oauth.ts | OAuth flow, state, redirect validation |
| worker/src/routes/webhooks.ts | Stripe verification, replay protection |
| worker/src/errors.ts | Tagged errors .. no leaking internals |
| worker/src/schemas.ts | Effect Schema for all data types |