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:
- Client-side Firebase Auth -- handles sign-in UI, token management, and real-time auth state.
- Server-side session cookie -- an HTTP-only
__sessioncookie minted by the Admin SDK, used to protect server-rendered pages and API routes. - Middleware gate -- Next.js middleware that checks for the
__sessioncookie 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:
Email Link (Passwordless)
The primary sign-in method. No password required -- users receive a magic link via email.
- User enters their email on the sign-in page.
sendEmailSignInLink(email, redirectUrl)calls Firebase'ssendSignInLinkToEmail(). The email is stored inlocalStoragefor link completion.- When the user clicks the link,
completeEmailLinkSignInIfPresent()detects the email-link parameters in the URL, retrieves the stored email, and callssignInWithEmailLink(). - 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.
Session Cookie Flow
The session cookie is the bridge between Firebase Auth (client) and server-side route protection. Here is how it works step by step:
Creating the Cookie
- The
AuthContextlistens foronIdTokenChangedevents from Firebase Auth. - When a user signs in or their token refreshes, the context calls
getIdToken()to get a fresh ID token. - The token is sent to
POST /api/auth/sessionwith the body{ idToken }. - The API route (
apps/web/src/app/api/auth/session/route.js) callsauth.createSessionCookie(idToken, { expiresIn })using the Firebase Admin SDK. - The resulting session cookie is set as
__sessionwith these properties:httpOnly: true-- not accessible to client-side JavaScriptsecure: truein productionsameSite: '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
sessionStorageand 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
nulluser during HMR or token refresh, the context waits 200ms and checksauth.currentUserbefore deleting the cookie to avoid unintended sign-outs.
Clearing the Cookie
On sign-out:
signOut(auth)is called on the Firebase client SDK.- A
DELETE /api/auth/sessionrequest clears the__sessioncookie by settingmaxAge: 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 Pattern | What 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 |
Email Link Bypass
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:
- The
AuthContexton the client, which reads subscription state from a real-time Firestore listener. - API route handlers that call
verifySessionCookie()orverifyIdToken()when they need the user's identity.
- The
AuthContext
The AuthProvider component (apps/web/src/context/AuthContext.tsx) is the central hub for auth state in the React tree. It provides:
State
| Property | Type | Description |
|---|---|---|
user | User | null | Firebase Auth user object |
isLoading | boolean | True while initial auth state is resolving |
claimsLoaded | boolean | True once subscription data is loaded from Firestore |
authReady | boolean | True when auth is fully resolved (not loading, claims loaded) |
hasActiveSub | boolean | Whether the user has an active subscription |
subscription | UserSubscription | null | Full subscription document from Firestore |
subscriptionStatus | SubscriptionStatus | 'active', 'trialing', 'past_due', 'canceled', or 'none' |
tier | Tier | 'free', 'starter', 'pro', or 'enterprise' |
features | Feature[] | Array of features available for the current tier |
isStaff | boolean | Whether the user has staff privileges |
Methods
| Method | Description |
|---|---|
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:
| Claim | Type | Description |
|---|---|---|
hasActiveSub | boolean | Whether the user has an active subscription |
subscriptionStatus | string | 'active', 'trialing', 'past_due', 'canceled', or 'none' |
tier | string | 'free', 'starter', 'pro', or 'enterprise' |
isStaff | boolean | Staff/admin privileges |
claimsUpdatedAt | number | Timestamp 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).
Session Cookie
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:
- The user navigates to the Settings page and clicks "Connect" for a platform.
- The app redirects to a Firebase Functions start endpoint that initiates the platform's OAuth flow.
- After the user authorizes, the platform redirects back to the app's current URL.
- 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
Cookie Security
- The
__sessioncookie 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: laxprovides 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
AuthContextavoids storing tokens inlocalStorage(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
onIdTokenChangedlistener uses a 200ms debounce to filter out transient null states.