What is CASA Tier 2?
If your app uses Gmail's restricted OAuth scopes (gmail.readonly, gmail.send, gmail.modify), Google requires a Cloud Application Security Assessment (CASA) before your app can go to production. Without it, your OAuth consent screen stays in "Testing" mode — capped at 100 users, with a scary warning screen.
CASA has multiple tiers. Tier 2 is the one that applies to most apps using restricted scopes. It requires:
- An OWASP ZAP DAST scan of your entire application
- Assessment by an authorized lab (from the App Defense Alliance list)
- Remediation of all findings
- A Letter of Validation (LOV) submitted to Google
- Annual recertification
This post covers what we actually encountered when inbox.dog went through the process.
Choosing an assessor
Google maintains a list of authorized CASA assessors. We used TAC Security and their ESOF AppSec ADA platform. Cost was around $550. Some assessors charge more.
The process:
- Submit your app URL and OAuth scopes to the assessor
- They run an automated OWASP ZAP scan against all your endpoints
- You get a report with findings categorized by severity
- Fix the findings, request a rescan
- Once clean, they issue a Letter of Validation
- Submit the LOV to Google via the API verification form
Turnaround was about a week from initial scan to LOV. The scan itself runs in a day.
What the scan actually found
Our first scan scored 9.7 out of 10. Ten findings total: 1 Low severity, 9 Informational. All were headers and configuration — no critical vulnerabilities.
Here's the exact list:
| Finding | Severity | Fix |
|---|---|---|
| Proxy Disclosure (CWE-204) | Low | Block TRACE/TRACK methods |
| X-Content-Type-Options Missing | Info | Add nosniff header |
| Content-Type Header Missing | Info | Ensure all responses have Content-Type |
| Application Error Disclosure | Info | Sanitize error responses |
| Strict-Transport-Security Not Set | Info | Add HSTS header |
| Suspicious Comments (TODO/DEBUG) | Info | Remove dev comments from production |
| Cache-Control Directives | Info | Cache-Control: no-store on tokens |
| Storable but Non-Cacheable Content | Info | Explicit cache directives on API responses |
| User Agent Fuzzer | Info | Consistent error handling across UAs |
| User Controllable HTML Attribute | Info | Escape user input in HTML attributes |
If you're building on Cloudflare Workers with Hono (like we are), most of these are a single middleware:
// Security headers middleware
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'");
});
// Block TRACE/TRACK
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();
}); For the landing page (static assets on Cloudflare Pages), add a _headers file in your public directory:
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data:; frame-ancestors 'none' Beyond the scan: what ZAP doesn't catch
The ZAP scan is a DAST tool — it probes your app from the outside. It's good at finding missing headers and obvious misconfigurations. It doesn't read your source code.
After passing the scan, we did a deeper code review against OWASP ASVS v4.0 and found issues the scan would never catch:
- Timing-safe comparisons: All our secret comparisons used
!==. An attacker could measure response times to brute-force secrets byte by byte. We wrote atimingSafeEqual()utility usingcrypto.subtleHMAC comparison. - Open redirect via redirect_uri: We weren't validating the
redirect_uriparameter against a per-client allowlist. An attacker could steal auth codes by specifying their own callback URL. We added aredirectUrisfield to API keys, validated on every/oauth/authorizecall. - Webhook replay: Our Stripe webhook handler didn't validate the timestamp or check for duplicate events. A replayed webhook could double-credit an account. We added timestamp validation (reject events >5 minutes old) and idempotency via session.id deduplication in KV.
- No rate limiting: Zero rate limiting on key creation or token exchange. We added KV-based rate limiting (5/min for key creation, 20/min for token exchange).
- No data deletion: Google requires a way for users to delete their data. We had no
DELETEendpoint for API keys.
We documented all of this in a CASA-CHECKLIST.md in our repo — 10 scan findings (Layer 1) and 28 code-level findings (Layer 2), with status tracking for each.
The actual cost and timeline
| Item | Cost |
|---|---|
| TAC Security assessment | ~$550 |
| Google Cloud project setup | $0 |
| OAuth consent screen review | $0 (but weeks of back-and-forth) |
| Annual recertification | ~$550/year |
| Total (first year) | ~$550 + your time |
Timeline: about 2-3 weeks from "I need Gmail access" to a production-approved OAuth consent screen, assuming you already have a working app. Most of that time is waiting on Google's review, not the CASA assessment itself.
Should you self-host or use a service?
inbox.dog is open source — you can self-host the entire OAuth proxy. But the CASA audit is per-application. If you self-host, you need your own audit.
The math is simple: at $0.10 per OAuth flow on the hosted version, $550 buys you 5,500 flows. If you need fewer than that, the hosted version is cheaper. If you need more, self-host and pay for the audit yourself.
Either way, you skip the Google Cloud Console setup, OAuth consent screen review, and months of back-and-forth with Google's trust and safety team.
Checklist for your own CASA audit
If you're going through this yourself, here's the minimum to pass the ZAP scan:
- Set
X-Content-Type-Options: nosniffon all responses - Set
Strict-Transport-Security: max-age=31536000; includeSubDomains - Set
X-Frame-Options: DENY - Set
Content-Security-Policy(at minimumframe-ancestors 'none') - Set
Referrer-Policy: strict-origin-when-cross-origin - Block TRACE and TRACK HTTP methods
- Return
Cache-Control: no-storeon any response containing tokens or secrets - Sanitize error responses — no stack traces, no internal details
- Remove TODO/FIXME/DEBUG comments from production-served HTML and JS
- Escape any user-controlled values rendered in HTML
And what the scan won't catch but you should fix anyway:
- Use timing-safe comparison for all secret/HMAC checks
- Validate
redirect_uriagainst a per-client allowlist - Add idempotency to payment webhooks
- Validate webhook timestamps
- Rate limit key creation and token exchange
- Provide a data deletion endpoint
Our full checklist with status tracking is at CASA-CHECKLIST.md.