Auth Graduation
Authentication is a spectrum, not a binary. Users progress from anonymous access to full accounts as their engagement deepens.
Not every visitor needs an account. Forcing login on first visit loses users who just want to look. Auth graduation captures engagement incrementally -- each step provides value before asking for more identity.
The Four Levels
| Level | Identity | Database Access | Tenant Context |
|---|---|---|---|
| ANONYMOUS | None | None | Not set |
| PREVIEW | Session cookie/KV, email captured | KV/R2 only | Not set |
| OAUTH | Google/GitHub via Firebase | Read-only via Hyperdrive | Set from user's tenant |
| FULL | Better Auth session in neon_auth | Full CRUD + RLS | Set and enforced |
Three Layers
The auth system is not one thing. It is three layers that compose:
Layer 1: Identity Provider
└── Firebase Auth / Google Identity Platform (europe-west6)
└── Handles: email verification, OAuth flows, identity federation
Layer 2: Session & Authorization
└── Better Auth in Neon (neon_auth schema)
└── Handles: sessions, RBAC, organization membership, account linking
Layer 3: Multi-Tenant IdP (activates later)
└── Google Identity Platform tenant pools
└── Handles: T1 corporate SSO, per-partner identity providersLayer 3 is dormant until a T1 partner needs corporate SSO. It activates without changing Layers 1 or 2.
Route-Level Enforcement
The Worker middleware enforces auth levels per route:
app.get('/api/public/*', noAuth());
app.get('/api/preview/*', requireAuth(AuthLevel.PREVIEW));
app.get('/api/content/*', requireAuth(AuthLevel.OAUTH));
app.get('/api/admin/*', requireAuth(AuthLevel.FULL));
app.get('/api/billing/*', requireAuth(AuthLevel.FULL, { role: 'admin' }));noAuth() means the route works for everyone. requireAuth(AuthLevel.PREVIEW) means the user needs at least a session cookie. Each level includes all levels below it -- a FULL user passes OAUTH and PREVIEW checks automatically.
Better Auth Configuration
Production config from BrandSyncUp:
export function createAuth(env: Env) {
const pool = new Pool({ connectionString: env.NEON_DATABASE_URL });
return betterAuth({
database: pool,
baseURL: env.APP_URL || 'https://brandsyncup.com',
secret: env.BETTER_AUTH_SECRET,
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
socialProviders: {
github: {
clientId: env.VITE_OAUTH_PUBLIC_GITHUB_LOGIN_APP_ID,
clientSecret: env.OAUTH_GITHUB_LOGIN_APP_SECRET,
},
google: {
clientId: env.VITE_OAUTH_PUBLIC_GOOGLE_CLIENT_ID,
clientSecret: env.OAUTH_GOOGLE_CLIENT_SECRET,
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // refresh daily
},
advanced: {
useSecureCookies: env.NODE_ENV === 'production',
generateId: () => crypto.randomUUID(),
},
});
}All secrets come from Doppler. Nothing hardcoded.
Graduation: OAuth to Full Account
When a user with an OAuth session needs full account access, the system links their identity:
async function graduateToFullAccount(
oauthUser: OAuthUser
): Promise<FullAccount> {
// Check for existing account with this email
const existing = await betterAuth.getAccountByEmail(oauthUser.email);
if (existing) {
return await betterAuth.linkAccount(
existing.id, 'google', oauthUser.sub
);
}
// Create new full account
const account = await betterAuth.createUser({
email: oauthUser.email,
name: oauthUser.name,
image: oauthUser.avatar,
accounts: [{
provider: 'google',
providerAccountId: oauthUser.sub,
}],
});
// Assign to tenant based on current context
await assignToTenant(account.id, resolveTenantFromContext());
return account;
}Account linking checks the provider before linking to prevent email-based account takeover. If someone signs in with Google, then tries to link a GitHub account with the same email, the system verifies provider identity -- not just email match.
Session Query
Sessions are tenant-scoped. This query resolves a session token to a user with their role and tenant memberships:
SELECT s.user_id AS "userId",
u.email,
m.role,
ARRAY_AGG(m2.organization_id) AS "tenantIds"
FROM neon_auth.session s
JOIN neon_auth.user u ON u.id = s.user_id
JOIN neon_auth.member m
ON m.user_id = s.user_id AND m.organization_id = $2
JOIN neon_auth.member m2 ON m2.user_id = s.user_id
WHERE s.token = $1 AND s.expires_at > NOW()
GROUP BY s.user_id, u.email, m.role$1 is the session token. $2 is the resolved tenant ID. The query returns the user's role within that specific tenant, plus all tenants they belong to.
DNS Layout
auth.brandsyncup.com → Firebase Hosting (email action URLs)
app.brandsyncup.com → Cloudflare Workers (app + API)WARNING
The auth.* subdomain must use DNS-only mode in Cloudflare (no orange cloud proxy). Firebase needs to provision its own SSL certificate for email action URLs. If CF proxies that subdomain, SSL provisioning fails.
Security (v0.5.0)
| Measure | Implementation |
|---|---|
| Account takeover prevention | Provider linkage check during graduation -- email match alone is not sufficient |
| Rate limiting | 20 req/min per IP on /api/auth/* via Cloudflare KV counters |
| Secure cookies | useSecureCookies: true in production, SameSite=Lax |
| Session expiry | 7-day sessions, daily refresh on active use |
| CORS | Explicit origin allowlist per environment |
Next Steps
- Multi-tenant architecture -- how tenants are structured
- Multi-domain resolution -- how requests become tenants
- Token governance across tiers -- how auth tiers interact with design tokens