Skip to main content

Authentication & Sessions

Soku uses Firebase Authentication on the client side and HTTP-only session cookies on the server side. This page walks through the full architecture, from sign-in to protected route enforcement.

Architecture Overview

The auth system has three layers:

  1. Client-side Firebase Auth -- handles sign-in UI, token management, and real-time auth state.
  2. Server-side session cookie -- an HTTP-only __session cookie minted by the Admin SDK, used to protect server-rendered pages and API routes.
  3. Middleware gate -- Next.js middleware that checks for the __session cookie on protected paths and redirects unauthenticated users.
User signs in (email link / Google / password)
|
v
Firebase Auth (client SDK)
|
v getIdToken()
POST /api/auth/session ──> Admin SDK createSessionCookie()
| |
v v
__session cookie set Cookie stored in browser
(HTTP-only, Secure, SameSite=Lax)
|
v
middleware.js checks __session on protected routes

Firebase Auth Configuration

Firebase Auth is initialized in apps/web/src/utils/firebaseClient.ts. The client SDK supports three sign-in methods:

The primary sign-in method. No password required -- users receive a magic link via email.

  1. User enters their email on the sign-in page.
  2. sendEmailSignInLink(email, redirectUrl) calls Firebase's sendSignInLinkToEmail(). The email is stored in localStorage for link completion.
  3. When the user clicks the link, completeEmailLinkSignInIfPresent() detects the email-link parameters in the URL, retrieves the stored email, and calls signInWithEmailLink().
  4. On success, the URL search params are cleaned up via history.replaceState.

Email + Password

Standard email/password auth is also available:

  • createUserWithEmailPassword(email, password) -- creates a new account.
  • signInWithEmailPassword(email, password) -- signs in an existing user.
  • sendPasswordReset(email) -- sends a password reset email.

After successful sign-in, the session cookie is immediately established by posting the ID token to /api/auth/session.

Google OAuth

Google sign-in uses a popup by default (signInWithPopup). A redirect-based flow (signInWithRedirect) is available as a fallback for environments where popups are blocked.

After the Google popup completes, the session cookie is established the same way as email sign-in.

The session cookie is the bridge between Firebase Auth (client) and server-side route protection. Here is how it works step by step:

  1. The AuthContext listens for onIdTokenChanged events from Firebase Auth.
  2. When a user signs in or their token refreshes, the context calls getIdToken() to get a fresh ID token.
  3. The token is sent to POST /api/auth/session with the body { idToken }.
  4. The API route (apps/web/src/app/api/auth/session/route.js) calls auth.createSessionCookie(idToken, { expiresIn }) using the Firebase Admin SDK.
  5. The resulting session cookie is set as __session with these properties:
    • httpOnly: true -- not accessible to client-side JavaScript
    • secure: true in production
    • sameSite: 'lax'
    • path: '/'
    • maxAge: 7 days

Deduplication and Rate Limiting

The AuthContext prevents unnecessary session sync calls:

  • Token deduplication: if the exact same token was already synced (tracked via sessionStorage and in-memory refs), the POST is skipped.
  • Rate limiting: token refreshes are throttled to once every 30 seconds, unless the token itself has changed.
  • Transient null guard: when Firebase Auth emits a temporary null user during HMR or token refresh, the context waits 200ms and checks auth.currentUser before deleting the cookie to avoid unintended sign-outs.

On sign-out:

  1. signOut(auth) is called on the Firebase client SDK.
  2. A DELETE /api/auth/session request clears the __session cookie by setting maxAge: 0.

Middleware and Protected Routes

Next.js middleware (apps/web/src/middleware.js) runs on every request matching the configured paths. Its job is simple: check for the __session cookie and redirect to /signin if it is missing.

Protected Paths

The middleware matcher covers these route patterns:

Path PatternWhat it Protects
/dashboard/*Main app dashboard
/create/*Content creation flows
/posts/*Post management
/templates/*Template library
/automations/*Automation workflows
/settings/*User settings and integrations
/admin/*Staff admin panel
/api-dashboard/*API dashboard

The middleware allows Firebase email-link callback URLs through without a session cookie. It detects these by checking for mode=signIn (or mode=signInViaEmailLink) and an oobCode query parameter.

What Middleware Does NOT Do

Middleware does not verify the session cookie cryptographically or check subscription status. This is intentional:

  • Performance: cookie verification via Admin SDK adds 700--2000ms per request. Checking presence alone is fast.
  • Subscription checks happen in two other places:
    1. The AuthContext on the client, which reads subscription state from a real-time Firestore listener.
    2. API route handlers that call verifySessionCookie() or verifyIdToken() when they need the user's identity.

AuthContext

The AuthProvider component (apps/web/src/context/AuthContext.tsx) is the central hub for auth state in the React tree. It provides:

State

PropertyTypeDescription
userUser | nullFirebase Auth user object
isLoadingbooleanTrue while initial auth state is resolving
claimsLoadedbooleanTrue once subscription data is loaded from Firestore
authReadybooleanTrue when auth is fully resolved (not loading, claims loaded)
hasActiveSubbooleanWhether the user has an active subscription
subscriptionUserSubscription | nullFull subscription document from Firestore
subscriptionStatusSubscriptionStatus'active', 'trialing', 'past_due', 'canceled', or 'none'
tierTier'free', 'starter', 'pro', or 'enterprise'
featuresFeature[]Array of features available for the current tier
isStaffbooleanWhether the user has staff privileges

Methods

MethodDescription
sendPasswordlessLink(email, redirectUrl?)Send a magic link email
signUpWithEmailPassword(email, password)Create account with email/password
signInWithEmailPassword(email, password)Sign in with email/password
signInWithGoogleOAuth()Sign in with Google popup
sendPasswordReset(email)Send password reset email
signOutUser()Sign out and clear session cookie
refreshClaims()Force refresh the ID token (subscription data updates automatically via Firestore listener)
hasFeature(feature)Check if the current tier includes a specific feature
getLimit(limitKey)Get the numeric limit for a given key on the current tier
canAdd(limitKey, currentCount)Check if the user is within the limit for adding more items

Subscription Data Source

The AuthContext subscribes to users/{uid}/subscription/current in Firestore using onSnapshot. This means subscription state updates in real time -- when a webhook writes to this document, the UI reflects the change immediately without a page refresh.

Usage

import { useAuth } from '@/context/AuthContext';

function DashboardHeader() {
const { user, tier, hasActiveSub, hasFeature } = useAuth();

if (!hasActiveSub) {
return <UpgradePrompt />;
}

return (
<div>
<span>Welcome, {user?.email}</span>
<span>Plan: {tier}</span>
{hasFeature('automations') && <AutomationsBadge />}
</div>
);
}

Custom Claims

Firebase custom claims are embedded in the ID token and propagated into the session cookie. They include:

ClaimTypeDescription
hasActiveSubbooleanWhether the user has an active subscription
subscriptionStatusstring'active', 'trialing', 'past_due', 'canceled', or 'none'
tierstring'free', 'starter', 'pro', or 'enterprise'
isStaffbooleanStaff/admin privileges
claimsUpdatedAtnumberTimestamp of last claims update

Claims are set by Stripe webhooks when subscription changes occur. On the client, claims can be read directly from the ID token via getIdTokenResult() without an API call. However, the primary source of truth for subscription state is now the Firestore document, not the claims.

The getUserClaims() helper in firebaseClient.ts parses and validates claims using the userClaimsSchema from @soku/schema.

ID Token Usage in API Calls

API routes that need to know who the caller is use one of two approaches:

Bearer Token (ID Token)

For routes that do not rely on the session cookie (such as the checkout flow), the client sends the Firebase ID token in the Authorization header:

const idToken = await user.getIdToken();

const response = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: {
'Authorization': `Bearer ${idToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ planId: 'pro' }),
});

The API route verifies the token with adminAuth.verifyIdToken(idToken).

For routes behind the middleware (such as billing portal), the __session cookie is sent automatically by the browser. The API route verifies it with adminAuth.verifySessionCookie(sessionCookie):

const sessionCookie = request.cookies.get('__session')?.value;
const decoded = await adminAuth.verifySessionCookie(sessionCookie);
const uid = decoded.uid;

OAuth Integrations for Social Platforms

Social platform integrations (TikTok, Instagram, etc.) use a separate OAuth flow from user authentication:

  1. The user navigates to the Settings page and clicks "Connect" for a platform.
  2. The app redirects to a Firebase Functions start endpoint that initiates the platform's OAuth flow.
  3. After the user authorizes, the platform redirects back to the app's current URL.
  4. The access token is stored server-side, associated with the user's Firebase UID.

For TikTok specifically, the desktop auth flow requires special handling: a ?desktop=1 parameter and a proxy callback route at /api/tiktok/callback. See the Local Development guide for details.

Security Considerations

  • The __session cookie is HTTP-only, preventing XSS attacks from reading it.
  • In production, the cookie is set with Secure: true, ensuring it is only sent over HTTPS.
  • SameSite: lax provides CSRF protection for most scenarios.
  • The cookie expires after 7 days. Users must re-authenticate after that.

Token Handling

  • ID tokens are short-lived (1 hour by default). Firebase refreshes them automatically.
  • The session cookie endpoint only accepts valid, non-expired ID tokens.
  • Token verification uses the Firebase Admin SDK, which validates the token signature against Google's public keys.

Sensitive Data

  • Never log session cookies or ID tokens in production.
  • The AuthContext avoids storing tokens in localStorage (only the email for passwordless link completion is stored temporarily).
  • Custom claims are readable from the token but cannot be modified client-side.

Rate Limiting

  • Session sync is rate-limited to prevent excessive API calls during rapid token refreshes.
  • The onIdTokenChanged listener uses a 200ms debounce to filter out transient null states.