Keeping it single-tenant (only you can sign in)¶
Supabase OAuth lets anyone with a GitHub account complete the sign-in flow — Supabase itself doesn't restrict who's allowed to authenticate. To keep bench.libearden.dev single-tenant, layer two gates:
| Layer | Where it runs | What it does |
|---|---|---|
| App-level allowlist | FastAPI middleware | After a JWT validates, checks the identity against env-driven allow vars. Anyone not on the list gets signed out + a 403. |
| Cloudflare Access | Cloudflare edge | Requires identity at Cloudflare's edge BEFORE the request ever reaches Fly. |
Layered = defense-in-depth. If one fails (typo'd env var, IdP misconfig), the other still blocks.
Inner gate: app-level allowlist (required)¶
Set at least one of these as a Fly secret. Either matches → access granted. Both empty = no restriction (which is the local-dev default; deliberately permissive).
# By user_id (UUID). Most stable — doesn't change if you rotate your
# GitHub username or primary email. After your first successful
# sign-in, find your UUID via the Supabase SQL editor:
# SELECT id FROM auth.users WHERE email = 'you@example.com';
flyctl secrets set LIS_ALLOWED_USER_IDS="<your-uuid>"
# Or by email — simpler if you don't want to sign in once first to
# look up the UUID. Case-insensitive. Comma-separate to allow multiple.
flyctl secrets set LIS_ALLOWED_EMAILS="you@example.com"
What happens when an unauthorized user signs in¶
- Their GitHub OAuth round-trip succeeds — Supabase issues them a JWT.
- Our auth middleware sees the session, runs
access_service.is_allowed(user_id, email). - They're not on the list → session cookies cleared (
sb-access-token,sb-refresh-token). - They get a 403 with the
access_denied.htmlpage explaining the situation. - The page links back to
/loginso they can sign out the rest of the way.
The check sits in app/main.py:_inject_session (the auth middleware), so every authenticated route is covered — not just specific endpoints. Adding new routes doesn't require remembering to gate them.
Local dev posture¶
.env.local typically leaves both vars unset → allowlist disabled → any signed-in user (i.e., you) can use the app. This is intentional so you don't have to add yourself to the allowlist every time you reset state.
Tests¶
tests/unit/test_access.py pins the contract. The most important assertion: empty allowlist = no restriction (so you don't accidentally lock yourself out by forgetting to set the var locally), but non-empty allowlist rejects-by-default (so a typo'd UUID doesn't quietly leave the door open).
Outer gate: Cloudflare Access (strongly recommended)¶
Cloudflare's zero-trust layer sits in front of Fly's load balancer. Visitors authenticate at Cloudflare's edge before Fly ever sees the request. Free tier covers up to 50 users — plenty for a single-tenant app.
Why bother if the app-level allowlist works?¶
Three reasons:
- Defense-in-depth: a future code change can't accidentally break the gate; Cloudflare Access is enforced upstream of your code.
- WAF + DDoS for free: protected apps get Cloudflare's WAF + edge cache as a side benefit.
- Blocks bots before they spin up your Fly machine: if
auto_stop_machines = "stop"and your app is sleeping, an unauthorized request never wakes it. Saves the cold-start cost.
Setup at the Cloudflare Zero Trust dashboard¶
Dashboard URL: https://one.dash.cloudflare.com
- Applications → Add an application → Self-hosted
- Application name:
Li's BenchSubdomain:benchDomain:libearden.devPath: leave empty — protects every path - Identity providers (Settings → Authentication → Login Methods):
- GitHub is the most natural pairing here — your Supabase OAuth uses GitHub too, so it's the same identity round-trip from the visitor's perspective. Cloudflare Access supports GitHub as a built-in IdP.
- Or One-time PIN for a passwordless email flow if you'd rather not depend on GitHub at the edge.
- Policies → Add a policy → Allow
- Selector:
Emails→you@example.com - Or
GitHub → Organizationsif you want anyone in a specific GH org. For single-user, email is cleaner. - Save.
Cloudflare now sets a CF_Authorization cookie on bench.libearden.dev after a successful identity check. First visit triggers the Cloudflare login screen.
Bypass Cloudflare Access for the health check¶
Otherwise Fly's load-balancer probe will get a 302 redirect to Cloudflare's login page on every check and the app will look "unhealthy" to Fly.
In the same Access policy editor:
- Add an additional policy → Bypass → Path matches:
/__health
Bypass policies don't require identity; they just let traffic through. The /__health route is intentionally non-sensitive (returns 200 ok).
After Cloudflare Access is enabled¶
The full request flow becomes:
visitor
↓
Cloudflare Access (identity check at edge)
↓
Cloudflare WAF + DDoS
↓
Fly LB
↓
FastAPI auth middleware (Supabase JWT verify)
↓
FastAPI access middleware (app-level allowlist)
↓
Your route handler
Three gates have to be misconfigured before an attacker gets a session.
Recovery: I locked myself out¶
App-level allowlist lockout¶
If you change emails / rotate UUIDs and end up on the wrong side of LIS_ALLOWED_*:
# From your local machine where you have flyctl auth:
flyctl secrets set LIS_ALLOWED_EMAILS="new@example.com"
# Or disable the gate while you fix things:
flyctl secrets unset LIS_ALLOWED_USER_IDS LIS_ALLOWED_EMAILS
Setting a secret triggers a redeploy automatically.
Cloudflare Access lockout¶
Sign in to https://one.dash.cloudflare.com (the dashboard is independent of your bench.libearden.dev Access policy — Cloudflare's own account auth, not the one you're configuring). Edit the Access policy. Add yourself, save, retry the visit.
If you lose access to your Cloudflare account entirely: that's a bigger problem (recover via the email on the account), but in the meantime your Fly app is still reachable at its underlying lis-bench.fly.dev hostname — that URL bypasses Cloudflare Access by definition. The app-level allowlist is still in force at that URL.
What this does NOT protect against¶
- Account takeover at GitHub: if someone steals your GitHub credentials, they pass both gates (since you're the allowlisted identity). Defense: GitHub 2FA + a strong unique password.
- Account takeover at Supabase: same shape — Supabase magic-link or password recovery sent to a compromised email would let an attacker get a JWT signed for your UUID. Defense: protect the email account that owns the Supabase OAuth identity.
- Postgres-level access via DATABASE_URL: if your
DATABASE_URLleaks, that's full-DB access regardless of the web gate. Keep it in Fly secrets only; never commit, never log. - Sentry / Anthropic API key leakage: same shape; the app-level gate doesn't protect those secrets. Rotate immediately if compromised.
The gates above protect the app's authenticated user surface. Account-level hygiene is a separate (and equally important) responsibility.
See also¶
- Deploy — for the broader deploy walkthrough
- LIS-280 hardening (Linear)