Search

Sign in to launch Copilot/Codex from the palette.

HOW WE PASSED GOOGLE CASA TIER 2 ON A CLOUDFLARE WORKER

8 security controls. One Worker. Open-sourced the checklist.

Security 2025.06.24

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. 1. Self-assessment. Complete Google's SAQ (Self-Assessment Questionnaire). Map your controls to OWASP ASVS.
  2. 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. 3. Lab review. A security assessor reviews your architecture, data flow, and controls. We used TAC Security. Cost: ~$550. Turnaround: ~2 weeks.
  4. 4. Remediation (if needed). Fix findings, resubmit. We passed on first submission.
  5. 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

FileControls
worker/src/index.tsHeaders, CORS, rate limiting, TRACE
worker/src/utils.tstimingSafeEqual, generateId, generateSecret
worker/src/crypto.tsAES-256-GCM encrypt/decrypt
worker/src/routes/oauth.tsOAuth flow, state, redirect validation
worker/src/routes/webhooks.tsStripe verification, replay protection
worker/src/errors.tsTagged errors .. no leaking internals
worker/src/schemas.tsEffect Schema for all data types

Related