TOC
- Production-Ready Authentication with ...
- οΈ Why Supabase? (vs Auth0/Firebase)
- What We Built: Feature Overview
- Part 1: Email/Password Auth
- Part 2: OAuth with GitHub and Google
- Part 3: Server-Side Rate Limiting
- οΈ Part 4: Row-Level Security (RLS) Po...
- β±οΈ Part 5: Session & Route Protection
- Part 6: Testing Authentication
- Part 7: What We Learned
- β Conclusion: Authentication Done Right
Supabase Authentication: OAuth & Security
π Production-Ready Authentication with Supabase: OAuth, Security, and Real-World Implementation
Authentication is the foundation of any application that handles user data. Get it wrong, and you're exposing your users to account takeovers, data breaches, and compliance nightmares. Get it right, and your users don't even noticeβthey just trust you.
This post documents our implementation of production-ready authentication in ScriptHammer using Supabase, complete with OAuth (Open Authorization) providers, server-side rate limiting, and database-level security policies. This isn't a "hello world" tutorialβthis is what we learned building authentication that actually ships to production.
ποΈ Why Supabase? (vs Auth0/Firebase)
After evaluating Auth0, Firebase Auth, and Supabase, we chose Supabase for three critical reasons:
- Database-First Security: Row-Level Security (RLS) policies live in PostgreSQL (Structured Query Language), not application code. Even if your API (Application Programming Interface) gets compromised, the database won't leak data.
- No Vendor Lock-In: Supabase runs on open-source PostgreSQL. If we ever need to migrate, we own the database schema and can export everything.
- Developer Experience: Built-in session management with @supabase/ssr for Next.js, automatic TypeScript type generation, and real-time subscriptions all in one package.
π¨ What We Built: Feature Overview
Here's what ships in our authentication system:
π Core Authentication Flows
- βοΈ Email/Password Authentication: Traditional sign-up with email verification
- π OAuth Providers: GitHub and Google single sign-on with Cross-Site Request Forgery (CSRF) protection
- π Password Reset: Secure token-based password recovery via email
- β±οΈ Session Management: 7-day default sessions, 30-day "Remember Me" option
π‘οΈ Security Hardening
- π¦ Server-Side Rate Limiting: 5 failed attempts per 15-minute window, enforced in PostgreSQL (client can't bypass)
- π OAuth CSRF Protection: Supabase's built-in OAuth2
stateparameter prevents session hijacking - π Audit Logging: Every authentication event logged to database with Internet Protocol (IP) address and user agent
- ποΈ Row-Level Security: Database policies ensure users only see their own data
π§ Developer Features
- π£οΈ Protected Routes: Middleware-based authorization checks
- π Type Safety: Generated TypeScript types from Supabase schema
- βοΈ React Context: Global
useAuth()hook for accessing user session - π§ͺ Test Infrastructure: Pre-configured test users for integration testing
π§ Part 1: Email/Password Auth
The Sign-Up Flow
Email/password authentication starts with user registration. Here's our SignUpForm component:
// src/components/auth/SignUpForm/SignUpForm.tsx
import { useState } from 'react';
import { supabase } from '@/lib/supabase/client';
import { validateEmail } from '@/lib/auth/email-validator';
import { checkRateLimit } from '@/lib/auth/rate-limit-check';
export function SignUpForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
// Validate email format and check for disposable domains
const emailValidation = validateEmail(email);
if (!emailValidation.valid) {
alert(emailValidation.errors.join(', '));
return;
}
// Server-side rate limit check (enforced in PostgreSQL)
const rateLimit = await checkRateLimit(email, 'sign_up');
if (!rateLimit.allowed) {
alert(`Too many attempts. Try again after ${rateLimit.locked_until}`);
return;
}
// Create user with Supabase Auth
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) throw error;
// User created - verification email sent
alert('Check your email for the verification link!');
} catch (error) {
console.error('Sign up error:', error);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSignUp}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email address"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password (min 8 chars)"
minLength={8}
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Creating account...' : 'Sign Up'}
</button>
</form>
);
}
Email Validation with TLD Checks
We enhanced Supabase's built-in validation with custom checks for Top-Level Domain (TLD) validity and disposable email detection:
// src/lib/auth/email-validator.ts
const VALID_TLDS = new Set([
'com',
'org',
'net',
'edu',
'gov',
'io',
'co',
'uk',
'us',
'ca',
'au',
'de',
'fr',
'it',
'es',
'app',
'dev',
'cloud',
'tech',
'ai',
]);
const DISPOSABLE_DOMAINS = new Set([
'tempmail.com',
'throwaway.email',
'10minutemail.com',
'guerrillamail.com',
'mailinator.com',
]);
export function validateEmail(email: string) {
const errors: string[] = [];
const warnings: string[] = [];
// RFC 5322 format check
const EMAIL_REGEX =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
if (!EMAIL_REGEX.test(email)) {
errors.push('Invalid email format');
}
// TLD validation
const tld = email.split('.').pop()?.toLowerCase();
if (!tld || !VALID_TLDS.has(tld)) {
errors.push('Invalid or missing top-level domain (TLD)');
}
// Disposable email detection (warning, not error)
const domain = email.split('@')[1];
if (domain && DISPOSABLE_DOMAINS.has(domain)) {
warnings.push(
'Disposable email detected - account recovery may be limited'
);
}
return {
valid: errors.length === 0,
errors,
warnings,
normalized: email.toLowerCase(),
};
}
Why validate on the client AND server? Client validation provides instant feedback. Server validation (in Supabase Edge Functions) prevents malicious clients from bypassing checks.
Email Verification Flow
After sign-up, Supabase sends a verification email with a token. The user clicks the link, which redirects to our callback page:
// src/app/auth/callback/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export default async function AuthCallbackPage({
searchParams,
}: {
searchParams: { code?: string };
}) {
const supabase = await createClient();
if (searchParams.code) {
// Exchange authorization code for session
const { error } = await supabase.auth.exchangeCodeForSession(
searchParams.code
);
if (error) {
return redirect('/sign-in?error=verification_failed');
}
// Email verified - redirect to dashboard
return redirect('/profile');
}
return redirect('/sign-in');
}
This callback handles both email verification and OAuth redirects (which we'll cover next).
π Part 2: OAuth with GitHub and Google
Why OAuth?
Password fatigue is real. Users reuse passwords across sites, creating security nightmares. OAuth lets users authenticate with providers they already trust (GitHub, Google) without creating another password.
π OAuth Flow with CSRF Protection
OAuth has a critical vulnerability: Cross-Site Request Forgery (CSRF) attacks. An attacker can initiate an OAuth flow and trick a victim into completing it, linking the attacker's GitHub account to the victim's app account.
π‘ The good news: with Supabase you don't hand-roll CSRF protection. The Supabase client automatically generates a cryptographically random OAuth2 state parameter, ties it to the browser, and verifies it when the provider redirects back β rejecting any mismatch. That is the standard, provider-recommended OAuth CSRF defense, so a self-managed state-token table only duplicates (more weakly) what Supabase already does. So the entire OAuth entry point is just:
// src/components/auth/OAuthButtons/OAuthButtons.tsx
import { supabase } from '@/lib/supabase/client';
export function OAuthButtons() {
const handleOAuth = async (provider: 'github' | 'google') => {
try {
// Supabase handles CSRF protection via its built-in OAuth2 state
// parameter β no need to manually manage state tokens.
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`,
scopes:
provider === 'github' ? 'read:user user:email' : 'email profile',
},
});
if (error) throw error;
// User redirected to provider's consent page
} catch (error) {
console.error('OAuth error:', error);
alert('Failed to initiate OAuth flow');
}
};
return (
<div className="flex flex-col gap-3">
<button onClick={() => handleOAuth('github')} className="btn btn-outline">
<svg /* GitHub icon SVG */></svg>
Continue with GitHub
</button>
<button onClick={() => handleOAuth('google')} className="btn btn-outline">
<svg /* Google icon SVG */></svg>
Continue with Google
</button>
</div>
);
}
β οΈ Gotcha: because ScriptHammer is a static export (no server to run a code exchange), the client is configured with flowType: 'implicit' (see src/lib/supabase/client.ts) β the provider returns the session token in the URL fragment and supabase-js picks it up on the callback. The state CSRF check is automatic either way; just register your callback URL in the Supabase dashboard's redirect allow-list. (A server-rendered app would instead use flowType: 'pkce' + exchangeCodeForSession.)
OAuth Callback Handling
When the user authorizes on GitHub/Google, they're redirected back to our callback. Under the implicit flow, supabase-js reads the session token from the URL fragment and validates the state parameter internally β so the callback doesn't hand-check anything; it just waits for the client to reflect the authenticated session:
// src/app/auth/callback/page.tsx (simplified)
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
export default function AuthCallbackPage() {
const { user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (isLoading) return;
// Supabase handles state validation internally β no manual check needed.
// supabase-js parses the token from the URL fragment and fires the auth
// state change; we just redirect once the session is present.
if (user) {
// ...populate the OAuth profile (non-blocking), then:
router.replace('/profile');
} else {
router.replace('/sign-in?error=oauth_failed');
}
}, [user, isLoading, router]);
return <p>Completing sign-inβ¦</p>;
}
π¦ Part 3: Server-Side Rate Limiting
β οΈ Critical: Client-side rate limiting is uselessβattackers can bypass JavaScript. We implemented PostgreSQL-based rate limiting that's impossible to bypass:
Database Function for Rate Limiting
-- supabase/migrations/20251006_complete_monolithic_setup.sql
CREATE TABLE rate_limit_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
identifier TEXT NOT NULL, -- Email or IP address
attempt_type TEXT NOT NULL CHECK (attempt_type IN ('sign_in', 'sign_up', 'password_reset')),
ip_address INET,
user_agent TEXT,
window_start TIMESTAMPTZ NOT NULL DEFAULT now(),
attempt_count INTEGER NOT NULL DEFAULT 1,
locked_until TIMESTAMPTZ, -- Lockout expiration
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_rate_limit_unique
ON rate_limit_attempts(identifier, attempt_type);
-- Function: Check if user is rate limited
CREATE OR REPLACE FUNCTION check_rate_limit(
p_identifier TEXT,
p_attempt_type TEXT,
p_ip_address INET DEFAULT NULL
)
RETURNS JSON AS $
DECLARE
v_record rate_limit_attempts%ROWTYPE;
v_max_attempts INTEGER := 5;
v_window_minutes INTEGER := 15;
v_now TIMESTAMPTZ := now();
BEGIN
-- Lock row to prevent race conditions
SELECT * INTO v_record
FROM rate_limit_attempts
WHERE identifier = p_identifier AND attempt_type = p_attempt_type
FOR UPDATE SKIP LOCKED;
-- Check if locked out
IF v_record.locked_until IS NOT NULL AND v_record.locked_until > v_now THEN
RETURN json_build_object(
'allowed', FALSE,
'remaining', 0,
'locked_until', v_record.locked_until,
'reason', 'rate_limited'
);
END IF;
-- Reset window if expired
IF v_record.id IS NULL OR (v_now - v_record.window_start) > (v_window_minutes || ' minutes')::INTERVAL THEN
INSERT INTO rate_limit_attempts (identifier, attempt_type, ip_address, window_start, attempt_count)
VALUES (p_identifier, p_attempt_type, p_ip_address, v_now, 0)
ON CONFLICT (identifier, attempt_type) DO UPDATE
SET window_start = v_now, attempt_count = 0, locked_until = NULL, updated_at = v_now;
RETURN json_build_object('allowed', TRUE, 'remaining', v_max_attempts, 'locked_until', NULL);
END IF;
-- Check attempt count
IF v_record.attempt_count < v_max_attempts THEN
RETURN json_build_object(
'allowed', TRUE,
'remaining', v_max_attempts - v_record.attempt_count,
'locked_until', NULL
);
ELSE
-- Lock out user
UPDATE rate_limit_attempts
SET locked_until = v_now + (v_window_minutes || ' minutes')::INTERVAL, updated_at = v_now
WHERE identifier = p_identifier AND attempt_type = p_attempt_type;
RETURN json_build_object(
'allowed', FALSE,
'remaining', 0,
'locked_until', v_now + (v_window_minutes || ' minutes')::INTERVAL,
'reason', 'rate_limited'
);
END IF;
END;
$ LANGUAGE plpgsql SECURITY DEFINER;
Client-Side Rate Limit Check
// src/lib/auth/rate-limit-check.ts
import { supabase } from '@/lib/supabase/client';
export interface RateLimitResult {
allowed: boolean;
remaining: number;
locked_until: string | null;
reason?: 'rate_limited';
}
export async function checkRateLimit(
identifier: string,
attemptType: 'sign_in' | 'sign_up' | 'password_reset',
ipAddress?: string
): Promise<RateLimitResult> {
const { data, error } = await supabase.rpc('check_rate_limit', {
p_identifier: identifier,
p_attempt_type: attemptType,
p_ip_address: ipAddress || null,
});
if (error) {
console.error('Rate limit check failed:', error);
// Fail open (allow request) rather than fail closed (block everyone)
return { allowed: true, remaining: 5, locked_until: null };
}
return data as unknown as RateLimitResult;
}
export async function recordFailedAttempt(
identifier: string,
attemptType: 'sign_in' | 'sign_up' | 'password_reset',
ipAddress?: string
): Promise<void> {
await supabase.rpc('record_failed_attempt', {
p_identifier: identifier,
p_attempt_type: attemptType,
p_ip_address: ipAddress || null,
});
}
This approach has three critical advantages:
- Impossible to Bypass: Enforced in PostgreSQL, not JavaScript
- No External Services: No Redis or Upstash needed
- Audit Trail: Every attempt logged with IP and user agent
ποΈ Part 4: Row-Level Security (RLS) Policies
Even if your API gets compromised, Row-Level Security (RLS) policies in PostgreSQL ensure users can't see each other's data.
Payment Data Isolation
-- Users can only view their own payment intents
CREATE POLICY "Users can view own payment intents" ON payment_intents
FOR SELECT USING (auth.uid() = template_user_id);
-- Users can only create payment intents for themselves
CREATE POLICY "Users can create own payment intents" ON payment_intents
FOR INSERT WITH CHECK (auth.uid() = template_user_id);
-- Payment intents are immutable (no UPDATE allowed)
CREATE POLICY "Payment intents are immutable" ON payment_intents
FOR UPDATE USING (false);
-- Users cannot delete payment records
CREATE POLICY "Payment intents cannot be deleted by users" ON payment_intents
FOR DELETE USING (false);
User Profile Access
-- Users view their own profile
CREATE POLICY "Users view own profile" ON user_profiles
FOR SELECT USING (auth.uid() = id);
-- Users update their own profile
CREATE POLICY "Users update own profile" ON user_profiles
FOR UPDATE USING (auth.uid() = id);
β Security Guarantee: These policies run at the database level, enforced by PostgreSQL. Even if an attacker compromises your Next.js API routes, they can't query other users' data.
β±οΈ Part 5: Session & Route Protection
AuthContext for Global Session State
We use React Context to provide authentication state throughout the app:
// src/contexts/AuthContext.tsx
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase/client';
import type { User, Session } from '@supabase/supabase-js';
interface AuthContextType {
user: User | null;
session: Session | null;
loading: boolean;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
});
// Listen for auth changes
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
setUser(session?.user ?? null);
});
return () => subscription.unsubscribe();
}, []);
const signOut = async () => {
await supabase.auth.signOut();
setSession(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, session, loading, signOut }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Client-Side Route Protection with
> π Note (updated): an earlier version of this post documented Next.js middleware (src/middleware.ts) for route protection. That pattern conflicts with output: 'export' (the static-export config ScriptHammer uses to deploy to GitHub Pages) β Next.js logs a Middleware cannot be used with "output: export" warning, and the middleware silently doesn't run in production. The implementation moved to a client-side guard component; the rewrite below reflects what actually ships on scripthammer.com.
For static-site deployments, route protection happens in a client component that wraps the page content and checks the auth context. The protected page imports the guard and renders its content inside:
// src/app/profile/page.tsx
import ProtectedRoute from '@/components/auth/ProtectedRoute';
import UserProfileCard from '@/components/auth/UserProfileCard';
export default function ProfilePage() {
return (
<ProtectedRoute>
<UserProfileCard />
</ProtectedRoute>
);
}
The guard reads the useAuth() hook and either renders the children, shows a "please sign in" card, or redirects to the sign-in page with a returnUrl:
// src/components/auth/ProtectedRoute/ProtectedRoute.tsx
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
import { useAuth } from '@/contexts/AuthContext';
export default function ProtectedRoute({
children,
redirectTo = '/sign-in',
}: {
children: React.ReactNode;
redirectTo?: string;
}) {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
const pathname = usePathname() || '/';
const wasAuthenticated = useRef(false);
// Remember if this mount was ever authenticated, so a transient
// token-refresh flip doesn't redirect the user mid-interaction.
useEffect(() => {
if (isAuthenticated) wasAuthenticated.current = true;
}, [isAuthenticated]);
useEffect(() => {
if (isLoading || isAuthenticated) return;
if (wasAuthenticated.current) return; // ignore transient flips
const returnUrl = encodeURIComponent(pathname);
const timer = setTimeout(() => {
router.push(`${redirectTo}?returnUrl=${returnUrl}`);
}, 500);
return () => clearTimeout(timer);
}, [isAuthenticated, isLoading, router, redirectTo, pathname]);
if (isLoading) {
return <span className="loading loading-spinner loading-lg" />;
}
if (!isAuthenticated && !wasAuthenticated.current) {
return (
<div className="card bg-base-100 max-w-md shadow-xl">
<h2>Authentication Required</h2>
<Link href={`${redirectTo}?returnUrl=${encodeURIComponent(pathname)}`}>
Sign In
</Link>
</div>
);
}
return <>{children}</>;
}
This pattern handles three things the server middleware was responsible for:
- Unauthenticated visitors to
/profile,/account,/payment-demosee a "Sign In" prompt instead of the page content, and get redirected to/sign-in?returnUrl=β¦. - Token refreshes that briefly flip
isAuthenticatedto false (a real Supabase behavior) are debounced by 500 ms and tracked via awasAuthenticatedref, so the user isn't yanked away mid-interaction. - Authenticated users browsing to
/sign-inare redirected away viaAuthContext.signOut()'swindow.location.href = '/'pattern combined with the sign-in page's own auth-aware effect.
If you're deploying to a Node host (Vercel, your own server) where middleware does run, the original middleware pattern is a perfectly good fit β just remove output: 'export' from next.config.ts and add the middleware file back. The choice is "where does the auth check run, browser or server?" rather than "which one is correct?"
π§ͺ Part 6: Testing Authentication
Integration Tests with Vitest
We test authentication flows with real Supabase calls:
// tests/integration/auth/sign-up-flow.test.ts
import { describe, it, expect } from 'vitest';
import { supabase } from '@/lib/supabase/client';
describe('Sign-Up Flow', () => {
const testEmail = process.env.TEST_USER_PRIMARY_EMAIL || 'test@example.com';
const testPassword =
process.env.TEST_USER_PRIMARY_PASSWORD || 'TestPassword123!';
it('should sign in with valid credentials', async () => {
const { data, error } = await supabase.auth.signInWithPassword({
email: testEmail,
password: testPassword,
});
expect(error).toBeNull();
expect(data.user).toBeDefined();
expect(data.session).toBeDefined();
expect(data.user?.email).toBe(testEmail);
});
it('should reject invalid credentials', async () => {
const { data, error } = await supabase.auth.signInWithPassword({
email: testEmail,
password: 'WrongPassword123!',
});
expect(error).toBeDefined();
expect(error?.message).toContain('Invalid login credentials');
expect(data.user).toBeNull();
});
});
E2E Tests with Playwright
End-to-End (E2E) tests verify the entire authentication flow in a real browser:
// e2e/auth/sign-in.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Sign-In Flow', () => {
test('should sign in successfully with valid credentials', async ({
page,
}) => {
await page.goto('/sign-in');
// Fill in credentials
await page.fill(
'input[type="email"]',
process.env.TEST_USER_PRIMARY_EMAIL!
);
await page.fill(
'input[type="password"]',
process.env.TEST_USER_PRIMARY_PASSWORD!
);
// Submit form
await page.click('button[type="submit"]');
// Should redirect to profile
await expect(page).toHaveURL('/profile');
await expect(page.getByText('Account Settings')).toBeVisible();
});
test('should show error with invalid credentials', async ({ page }) => {
await page.goto('/sign-in');
await page.fill('input[type="email"]', 'wrong@example.com');
await page.fill('input[type="password"]', 'WrongPassword123!');
await page.click('button[type="submit"]');
// Should show error message
await expect(page.getByText(/invalid login credentials/i)).toBeVisible();
});
});
π‘ Part 7: What We Learned
Lesson 1: Cookies vs localStorage
For static sites with no server-side code exchange, we use localStorage for session tokens with Supabase's implicit flow:
// src/lib/supabase/client.ts
export function createClient(): SupabaseClient<Database> {
const supabaseInstance = createSupabaseClient<Database>(
supabaseUrl,
supabaseAnonKey,
{
auth: {
// Use implicit flow for static sites (no server-side code exchange)
flowType: 'implicit',
// Store session in localStorage
storage:
typeof window !== 'undefined' ? window.localStorage : undefined,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true,
},
}
);
return supabaseInstance;
}
For server-side authentication (SSR), use @supabase/ssr with httpOnly cookies as shown in the middleware section.
Lesson 2: Test Isolation & Cleanup
Our tests initially failed because of leftover database state. When testing rate limiting or OAuth flows, always clean up database records in beforeEach:
beforeEach(async () => {
// Clean up rate limit attempts
await supabase
.from('rate_limit_attempts')
.delete()
.eq('identifier', testEmail);
});
Lesson 3: Isolate OAuth State
When running multiple OAuth tests, shared localStorage caused state token collisions. Solution: use separate storage keys per test client:
const userAClient = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storageKey: 'test-user-a-session', // Unique per client
},
});
const userBClient = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storageKey: 'test-user-b-session', // Different key
},
});
Lesson 4: Fail Open on Rate Errors
When the rate limit database query fails, fail open (allow the request) rather than fail closed (block everyone):
export async function checkRateLimit(...args) {
const { data, error } = await supabase.rpc('check_rate_limit', ...);
if (error) {
console.error('Rate limit check failed:', error);
// Fail open - allow request rather than blocking everyone
return { allowed: true, remaining: 5, locked_until: null };
}
return data;
}
This prevents a database outage from locking out all users.
β Conclusion: Authentication Done Right
Building production authentication isn't about copying Auth0's API. It's about understanding the security principles:
- Defense in Depth: Rate limiting in PostgreSQL, RLS policies at database level, CSRF tokens for OAuth
- Fail Safely: Fail open on errors, provide clear error messages, don't lock out legitimate users
- Test Realistically: Integration tests with real Supabase, E2E tests in real browsers, database cleanup between tests
Next up: Offline-First Payment System with Stripe and PayPal - how we handle payments on static sites with Supabase Edge Functions.
Want to see the full implementation? Check out the ScriptHammer GitHub repository.
