t { error } = await supabase.auth.linkIdentity({
provider,
options: {
redirectTo: ${window.location.origin}/auth/complete,
},
});
if (error) throw new Error(`Linking failed: ${error.message}`);
}, [supabase]);
return { initiateSocialLogin, linkProvider };
}
```typescript
// components/auth/social-login-button.tsx
'use client';
import { useIdentityManager } from '@/hooks/use-identity-manager';
import { useState } from 'react';
interface SocialLoginButtonProps {
provider: 'google' | 'github' | 'discord';
label: string;
}
export function SocialLoginButton({ provider, label }: SocialLoginButtonProps) {
const { initiateSocialLogin } = useIdentityManager();
const [isProcessing, setIsProcessing] = useState(false);
const handleLogin = async () => {
setIsProcessing(true);
try {
await initiateSocialLogin(provider);
} catch (err) {
console.error(err);
setIsProcessing(false);
}
};
return (
<button
onClick={handleLogin}
disabled={isProcessing}
className="flex items-center gap-2 px-4 py-2 border rounded hover:bg-gray-50 disabled:opacity-50"
>
{isProcessing ? 'Redirecting...' : label}
</button>
);
}
2. Frictionless Passwordless Authentication
Passwordless flows reduce credential stuffing risks and improve conversion. We implement a two-step OTP flow for SMS/Email that separates the request from verification.
Implementation Rationale:
- State Separation: The form manages UI state, while the hook manages the Supabase interaction.
- Type Safety: Strict typing for OTP types prevents runtime errors.
- User Experience: Immediate feedback on send status reduces abandonment.
// hooks/use-passwordless-auth.ts
import { createClient } from '@/lib/supabase/client';
import { useCallback } from 'react';
type OtpChannel = 'email' | 'sms';
export function usePasswordlessAuth() {
const supabase = createClient();
const requestOtp = useCallback(async (identifier: string, channel: OtpChannel) => {
const payload = channel === 'email'
? { email: identifier }
: { phone: identifier };
const { error } = await supabase.auth.signInWithOtp({
...payload,
options: {
shouldCreateUser: true,
emailRedirectTo: `${window.location.origin}/auth/complete`,
},
});
if (error) throw new Error(`OTP request failed: ${error.message}`);
}, [supabase]);
const verifyOtp = useCallback(async (identifier: string, token: string, channel: OtpChannel) => {
const payload = channel === 'email'
? { email: identifier, token, type: 'email' as const }
: { phone: identifier, token, type: 'sms' as const };
const { error } = await supabase.auth.verifyOtp(payload);
if (error) throw new Error(`Verification failed: ${error.message}`);
}, [supabase]);
return { requestOtp, verifyOtp };
}
3. Enterprise Token Engineering
For applications requiring authorization at the edge or in downstream services, Supabase's default JWT may lack necessary context. We engineer a custom token using the jose library, which is optimized for Edge runtimes and modern Node.js.
Implementation Rationale:
jose over jsonwebtoken: jose provides better TypeScript support, is Web Crypto API compliant, and works seamlessly in Edge environments without native dependencies.
- Minimal Claims: Only include data required for routing and access control. Fetch detailed user data via RLS-protected queries to keep token size small.
- Short Expiry: Access tokens should expire quickly (e.g., 15 minutes) to limit the blast radius of token theft.
// lib/token-engine.ts
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
import { NextRequest } from 'next/server';
export interface EnterpriseClaims extends JWTPayload {
sub: string;
org_id: string;
role: 'owner' | 'admin' | 'member' | 'viewer';
permissions: string[];
tenant_context?: string;
}
const SECRET_KEY = new TextEncoder().encode(process.env.JWT_SIGNING_SECRET!);
export async function signEnterpriseToken(claims: Omit<EnterpriseClaims, 'iat' | 'exp'>): Promise<string> {
return new SignJWT(claims)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(SECRET_KEY);
}
export async function verifyEnterpriseToken(token: string): Promise<EnterpriseClaims | null> {
try {
const { payload } = await jwtVerify(token, SECRET_KEY);
return payload as EnterpriseClaims;
} catch {
return null;
}
}
export function extractTokenFromRequest(req: NextRequest): string | null {
const authHeader = req.headers.get('authorization');
if (authHeader?.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return req.cookies.get('enterprise-token')?.value ?? null;
}
4. Multi-Tenant Context Resolution
Multi-tenancy requires strict isolation. We implement a resolver that validates tenant membership before granting access to tenant-scoped routes.
Implementation Rationale:
- Explicit Validation: Never trust URL parameters for tenant IDs. Always verify membership against the database.
- Context Injection: Once validated, inject the tenant context into headers or cookies for downstream components.
- RLS Synergy: This resolver works alongside Supabase RLS. The resolver gates route access; RLS gates data access. Defense in depth.
// lib/tenant-resolver.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { NextRequest } from 'next/server';
export async function resolveTenantContext(
request: NextRequest,
tenantSlug: string
): Promise<{ userId: string; tenantId: string; role: string } | null> {
const cookieStore = cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get: (name) => cookieStore.get(name)?.value,
set: (name, value, opts) => cookieStore.set({ name, value, ...opts }),
remove: (name, opts) => cookieStore.set({ name, value: '', ...opts }),
},
}
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
// Verify membership and fetch role
const { data: membership, error } = await supabase
.from('tenant_memberships')
.select('tenant_id, role')
.eq('user_id', user.id)
.eq('tenant_slug', tenantSlug)
.single();
if (error || !membership) return null;
return {
userId: user.id,
tenantId: membership.tenant_id,
role: membership.role,
};
}
Pitfall Guide
1. Callback Race Conditions
Explanation: Users may refresh the callback page or click the back button, causing the authorization code to be exchanged multiple times or expire, leading to errors.
Fix: Implement idempotent callback handlers. Check if a session already exists before exchanging the code. Use a loading state to disable the callback UI during processing.
2. Token Bloat
Explanation: Injecting excessive user data (e.g., full profile, preferences) into JWT claims increases payload size, slowing down network requests and exceeding header limits.
Fix: Adhere to the principle of minimal claims. Include only sub, role, and essential identifiers. Fetch detailed data via secure API calls protected by RLS.
3. Insecure Identity Linking
Explanation: Allowing users to link OAuth providers without re-authentication can lead to account takeover if the original session was hijacked.
Fix: Require password re-entry or a fresh OTP verification before executing linkIdentity. This ensures the user is present and authorized to modify identity associations.
4. Middleware Blocking Overhead
Explanation: Performing heavy database queries or complex token verification in Next.js middleware can increase Time to First Byte (TTFB) significantly.
Fix: Run middleware on the Edge runtime. Cache token verification results where possible. Offload heavy tenant resolution to server components or API routes when strict edge performance isn't required.
5. Multi-Tenant Data Leakage
Explanation: Relying solely on middleware for tenant isolation without RLS policies allows direct API access to bypass tenant checks.
Fix: Always implement RLS policies that filter data by tenant_id. Treat middleware as a UX guard, not the sole security boundary. Ensure all database queries include the tenant context.
6. OTP Rate Limiting Abuse
Explanation: Attackers can abuse OTP endpoints to spam users or enumerate phone numbers.
Fix: Configure Supabase rate limits for OTP endpoints. Implement additional throttling in your API layer. Monitor for anomalous request patterns and block suspicious IPs.
7. Stale Refresh Tokens
Explanation: Refresh tokens may become invalid if a user changes their password or is removed from a tenant, but the client continues to attempt refreshes.
Fix: Handle refresh token errors gracefully. Implement a fallback to re-authentication. Use Supabase's onAuthStateChange to detect session invalidation and clear local state immediately.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Multi-Tenant SaaS | Custom JWT + RLS | JWT handles routing/roles efficiently; RLS ensures data isolation. | Medium (Dev effort) |
| Consumer Mobile App | Passwordless OTP + Session Cookies | Low friction login; session cookies simplify state management. | Low |
| Enterprise B2B | SSO/SAML + Custom Claims | Compliance requirements; SSO integrates with corporate identity. | High (Provider costs) |
| High-Throughput API | JWT Verification at Edge | Reduces latency by avoiding DB lookups for every request. | Low (Compute) |
| Regulated Data | RLS-First + Audit Logs | Strict data access control; auditability required for compliance. | Medium (Storage) |
Configuration Template
middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { verifyEnterpriseToken, extractTokenFromRequest } from '@/lib/token-engine';
export async function middleware(request: NextRequest) {
const token = extractTokenFromRequest(request);
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
const claims = await verifyEnterpriseToken(token);
if (!claims) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Role-based routing
if (request.nextUrl.pathname.startsWith('/admin') && claims.role !== 'admin') {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
// Inject claims for downstream use
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', claims.sub);
requestHeaders.set('x-tenant-id', claims.org_id);
requestHeaders.set('x-user-role', claims.role);
return NextResponse.next({
request: { headers: requestHeaders },
});
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/api/protected/:path*'],
};
lib/supabase-server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export function createServerSupabaseClient() {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get: (name) => cookieStore.get(name)?.value,
set: (name, value, options) => cookieStore.set({ name, value, ...options }),
remove: (name, options) => cookieStore.set({ name, value: '', ...options }),
},
}
);
}
Quick Start Guide
- Initialize Supabase Project: Create a new project in the Supabase dashboard and enable the required OAuth providers and email OTP settings.
- Configure Environment Variables: Add
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and JWT_SIGNING_SECRET to your .env.local.
- Setup Client Wrappers: Create the server and client Supabase client wrappers using the templates above to ensure consistent cookie handling.
- Implement Middleware: Add the
middleware.ts configuration to protect routes and verify tokens at the edge.
- Build Auth Components: Integrate the
SocialLoginButton and usePasswordlessAuth hook into your login pages, then test the full flow including callback handling and token verification.