Skip to content

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

LevelIdentityDatabase AccessTenant Context
ANONYMOUSNoneNoneNot set
PREVIEWSession cookie/KV, email capturedKV/R2 onlyNot set
OAUTHGoogle/GitHub via FirebaseRead-only via HyperdriveSet from user's tenant
FULLBetter Auth session in neon_authFull CRUD + RLSSet 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 providers

Layer 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:

typescript
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:

typescript
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:

typescript
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:

sql
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)

MeasureImplementation
Account takeover preventionProvider linkage check during graduation -- email match alone is not sufficient
Rate limiting20 req/min per IP on /api/auth/* via Cloudflare KV counters
Secure cookiesuseSecureCookies: true in production, SameSite=Lax
Session expiry7-day sessions, daily refresh on active use
CORSExplicit origin allowlist per environment

Next Steps

Released under the MIT License.