Phase 1: Foundation & Auth - Standard stack identified (Supabase Auth + @supabase/ssr) - Architecture patterns documented (dual-client, RLS, middleware) - Subscription schema researched (plans table + JSONB features) - 7 common pitfalls catalogued (missing RLS, session expiry, etc.) - Code examples verified from official Supabase docs Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
984 lines
36 KiB
Markdown
984 lines
36 KiB
Markdown
# Phase 1: Foundation & Auth - Research
|
|
|
|
**Researched:** 2026-01-31
|
|
**Domain:** Authentication, Multi-Tenancy, Subscription Management
|
|
**Confidence:** HIGH
|
|
|
|
## Summary
|
|
|
|
Phase 1 establishes the authentication foundation using Supabase Auth integrated with Next.js 14 App Router. The research confirms that Supabase provides a production-ready authentication system with built-in email/password and OAuth support, session management, and Row Level Security (RLS) for multi-tenant data isolation.
|
|
|
|
The standard approach uses cookie-based authentication via the `@supabase/ssr` package (replacing the deprecated `@supabase/auth-helpers-nextjs`), with separate client patterns for Server Components vs Client Components. RLS policies enforce tenant isolation at the database level, with `tenant_id` stored in JWT `app_metadata` for optimal performance.
|
|
|
|
Subscription plans are modeled using a simple database schema: `plans` table for tier definitions (Free/Creator/Pro), `user_profiles` table linking users to their plan, and feature limits stored as JSONB for flexibility. The architecture follows a "defense in depth" strategy: RLS for baseline security, application logic for business rules, and middleware for route protection.
|
|
|
|
**Primary recommendation:** Use Supabase's official `@supabase/ssr` package with cookie-based auth, implement RLS policies from day one (never skip this), store `tenant_id` in JWT `app_metadata` for performance, and model plans as database tables with JSONB for feature limits.
|
|
|
|
## Standard Stack
|
|
|
|
The established libraries/tools for Supabase Auth with Next.js 14:
|
|
|
|
### Core
|
|
| Library | Version | Purpose | Why Standard |
|
|
|---------|---------|---------|--------------|
|
|
| @supabase/supabase-js | Latest | Supabase client SDK | Official JavaScript client for all Supabase features |
|
|
| @supabase/ssr | Latest | Server-side rendering support | Official package for Next.js App Router (replaces deprecated auth-helpers) |
|
|
| Next.js | 14+ | Frontend framework | App Router required for modern auth patterns |
|
|
| PostgreSQL | 15+ | Database | Supabase's underlying database with RLS support |
|
|
|
|
### Supporting
|
|
| Library | Version | Purpose | When to Use |
|
|
|---------|---------|---------|-------------|
|
|
| zod | 3.x | Schema validation | Validate registration/login forms, password requirements |
|
|
| react-hook-form | 7.x | Form management | Handle auth forms with validation |
|
|
| @supabase/auth-ui-react | Latest | Pre-built auth UI | Optional: rapid prototyping (customization limited) |
|
|
|
|
### Alternatives Considered
|
|
| Instead of | Could Use | Tradeoff |
|
|
|------------|-----------|----------|
|
|
| Supabase Auth | NextAuth.js (Auth.js) | More provider flexibility, but requires managing database schema, no RLS integration |
|
|
| Supabase Auth | Clerk | Better UX, but expensive ($25/mo for 10k users vs Supabase free 50k MAU), vendor lock-in |
|
|
| Cookie-based sessions | JWT in localStorage | Faster client-side checks, but vulnerable to XSS attacks, no SSR support |
|
|
|
|
**Installation:**
|
|
```bash
|
|
npm install @supabase/supabase-js @supabase/ssr zod react-hook-form
|
|
```
|
|
|
|
## Architecture Patterns
|
|
|
|
### Recommended Project Structure
|
|
```
|
|
app/
|
|
├── (auth)/ # Auth route group (unauthenticated layout)
|
|
│ ├── login/
|
|
│ ├── register/
|
|
│ ├── verify-email/
|
|
│ └── reset-password/
|
|
├── (dashboard)/ # Protected route group (authenticated layout)
|
|
│ ├── settings/
|
|
│ └── subscription/
|
|
├── api/
|
|
│ └── auth/
|
|
│ └── callback/ # OAuth callback handler
|
|
└── actions/ # Server Actions for auth operations
|
|
└── auth.ts
|
|
|
|
lib/
|
|
├── supabase/
|
|
│ ├── client.ts # Client Component client
|
|
│ ├── server.ts # Server Component client
|
|
│ └── middleware.ts # Middleware helper
|
|
└── schemas/
|
|
└── auth.ts # Zod validation schemas
|
|
|
|
middleware.ts # Route protection + session refresh
|
|
```
|
|
|
|
### Pattern 1: Dual Client Creation
|
|
**What:** Create separate Supabase client instances for Client Components vs Server Components
|
|
**When to use:** Always - Next.js App Router runs code in two environments (browser and server)
|
|
|
|
**Example:**
|
|
```typescript
|
|
// lib/supabase/client.ts (Client Components)
|
|
import { createBrowserClient } from '@supabase/ssr'
|
|
|
|
export function createClient() {
|
|
return createBrowserClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
|
)
|
|
}
|
|
|
|
// lib/supabase/server.ts (Server Components, Route Handlers, Server Actions)
|
|
import { createServerClient } from '@supabase/ssr'
|
|
import { cookies } from 'next/headers'
|
|
|
|
export async function createClient() {
|
|
const cookieStore = await cookies()
|
|
|
|
return createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return cookieStore.getAll()
|
|
},
|
|
setAll(cookiesToSet) {
|
|
try {
|
|
cookiesToSet.forEach(({ name, value, options }) =>
|
|
cookieStore.set(name, value, options)
|
|
)
|
|
} catch {
|
|
// The `setAll` method was called from a Server Component.
|
|
// This can be ignored if you have middleware refreshing user sessions.
|
|
}
|
|
},
|
|
},
|
|
}
|
|
)
|
|
}
|
|
```
|
|
**Source:** [Supabase Server-Side Auth for Next.js](https://supabase.com/docs/guides/auth/server-side/nextjs)
|
|
|
|
### Pattern 2: Middleware Session Refresh
|
|
**What:** Use middleware to refresh user sessions on every request and protect routes
|
|
**When to use:** MANDATORY - without this, sessions expire and authentication breaks
|
|
|
|
**Example:**
|
|
```typescript
|
|
// middleware.ts
|
|
import { type NextRequest } from 'next/server'
|
|
import { updateSession } from '@/lib/supabase/middleware'
|
|
|
|
export async function middleware(request: NextRequest) {
|
|
return await updateSession(request)
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
|
],
|
|
}
|
|
|
|
// lib/supabase/middleware.ts
|
|
import { createServerClient } from '@supabase/ssr'
|
|
import { NextResponse, type NextRequest } from 'next/server'
|
|
|
|
export async function updateSession(request: NextRequest) {
|
|
let supabaseResponse = NextResponse.next({
|
|
request,
|
|
})
|
|
|
|
const supabase = createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return request.cookies.getAll()
|
|
},
|
|
setAll(cookiesToSet) {
|
|
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
|
|
supabaseResponse = NextResponse.next({
|
|
request,
|
|
})
|
|
cookiesToSet.forEach(({ name, value, options }) =>
|
|
supabaseResponse.cookies.set(name, value, options)
|
|
)
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
// Refresh session if expired
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
// Protect routes
|
|
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
|
|
const url = request.nextUrl.clone()
|
|
url.pathname = '/login'
|
|
return NextResponse.redirect(url)
|
|
}
|
|
|
|
return supabaseResponse
|
|
}
|
|
```
|
|
**Source:** [Supabase Auth with Next.js App Router](https://supabase.com/docs/guides/auth/auth-helpers/nextjs)
|
|
|
|
### Pattern 3: RLS Policies for Multi-Tenancy
|
|
**What:** Use Row Level Security policies to enforce tenant isolation at database level
|
|
**When to use:** ALWAYS - this is the security foundation, never skip RLS
|
|
|
|
**Example:**
|
|
```sql
|
|
-- Enable RLS on all tables in public schema
|
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Helper function to get tenant_id from JWT
|
|
CREATE OR REPLACE FUNCTION auth.tenant_id()
|
|
RETURNS TEXT
|
|
LANGUAGE SQL STABLE
|
|
AS $$
|
|
SELECT NULLIF(
|
|
((current_setting('request.jwt.claims')::jsonb ->> 'app_metadata')::jsonb ->> 'tenant_id'),
|
|
''
|
|
)::TEXT
|
|
$$;
|
|
|
|
-- RLS Policy: Users can only access their tenant's data
|
|
CREATE POLICY "Tenant isolation"
|
|
ON profiles
|
|
FOR ALL
|
|
USING (tenant_id = (SELECT auth.tenant_id()));
|
|
|
|
-- RLS Policy: Users can only INSERT with their tenant_id
|
|
CREATE POLICY "Insert with tenant"
|
|
ON profiles
|
|
FOR INSERT
|
|
WITH CHECK (tenant_id = (SELECT auth.tenant_id()));
|
|
|
|
-- Important: Wrap auth functions in SELECT for 99% performance improvement
|
|
-- Good: (SELECT auth.uid()) = user_id
|
|
-- Bad: auth.uid() = user_id
|
|
```
|
|
**Source:** [Supabase Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
|
|
|
|
### Pattern 4: Subscription Plans Schema
|
|
**What:** Database schema for plans, user subscriptions, and feature limits
|
|
**When to use:** For any SaaS with tiered pricing
|
|
|
|
**Example:**
|
|
```sql
|
|
-- Plans table (Free, Creator, Pro)
|
|
CREATE TABLE plans (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name TEXT NOT NULL UNIQUE, -- 'free', 'creator', 'pro'
|
|
display_name TEXT NOT NULL, -- 'Free Plan', 'Creator Plan', 'Pro Plan'
|
|
price_monthly INTEGER NOT NULL, -- in cents (0, 1900, 4900)
|
|
features JSONB NOT NULL, -- Flexible feature limits
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Example plan features JSONB:
|
|
-- {
|
|
-- "posts_per_month": 10,
|
|
-- "ai_models": ["gpt-4o-mini"],
|
|
-- "social_accounts": 1,
|
|
-- "analytics": false
|
|
-- }
|
|
|
|
-- User profiles with plan assignment
|
|
CREATE TABLE profiles (
|
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
tenant_id UUID NOT NULL, -- For multi-tenancy
|
|
plan_id UUID REFERENCES plans(id) NOT NULL,
|
|
email TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Index for tenant isolation (critical for performance)
|
|
CREATE INDEX idx_profiles_tenant_id ON profiles(tenant_id);
|
|
|
|
-- RLS policies
|
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "Users can read own profile"
|
|
ON profiles FOR SELECT
|
|
USING ((SELECT auth.uid()) = id);
|
|
|
|
CREATE POLICY "Users can update own profile"
|
|
ON profiles FOR UPDATE
|
|
USING ((SELECT auth.uid()) = id)
|
|
WITH CHECK ((SELECT auth.uid()) = id);
|
|
```
|
|
**Source:** [Multi-tenant SaaS model with PostgreSQL](https://www.checklyhq.com/blog/building-a-multi-tenant-saas-data-model/)
|
|
|
|
### Pattern 5: Email Verification Flow (PKCE)
|
|
**What:** Secure email verification using PKCE flow for SSR apps
|
|
**When to use:** When email verification is mandatory (as per phase requirements)
|
|
|
|
**Example:**
|
|
```typescript
|
|
// app/actions/auth.ts
|
|
'use server'
|
|
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { z } from 'zod'
|
|
|
|
const registerSchema = z.object({
|
|
email: z.string().email('Email non valida'),
|
|
password: z.string()
|
|
.min(8, 'Minimo 8 caratteri')
|
|
.regex(/[0-9]/, 'Deve contenere almeno un numero')
|
|
.regex(/[A-Z]/, 'Deve contenere almeno una maiuscola'),
|
|
})
|
|
|
|
export async function register(formData: FormData) {
|
|
const supabase = await createClient()
|
|
|
|
const parsed = registerSchema.safeParse({
|
|
email: formData.get('email'),
|
|
password: formData.get('password'),
|
|
})
|
|
|
|
if (!parsed.success) {
|
|
return { error: parsed.error.flatten().fieldErrors }
|
|
}
|
|
|
|
const { data, error } = await supabase.auth.signUp({
|
|
email: parsed.data.email,
|
|
password: parsed.data.password,
|
|
options: {
|
|
emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
|
|
data: {
|
|
// Additional user metadata if needed
|
|
}
|
|
}
|
|
})
|
|
|
|
if (error) {
|
|
// Return SPECIFIC error messages (per user requirement)
|
|
if (error.message.includes('already registered')) {
|
|
return { error: 'Email già registrata' }
|
|
}
|
|
return { error: error.message }
|
|
}
|
|
|
|
return { success: true, message: 'Controlla la tua email per confermare' }
|
|
}
|
|
|
|
// app/auth/callback/route.ts - OAuth callback handler
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
|
|
export async function GET(request: Request) {
|
|
const { searchParams, origin } = new URL(request.url)
|
|
const code = searchParams.get('code')
|
|
const next = searchParams.get('next') ?? '/dashboard'
|
|
|
|
if (code) {
|
|
const supabase = await createClient()
|
|
const { error } = await supabase.auth.exchangeCodeForSession(code)
|
|
if (!error) {
|
|
return NextResponse.redirect(`${origin}${next}`)
|
|
}
|
|
}
|
|
|
|
// Return the user to an error page with instructions
|
|
return NextResponse.redirect(`${origin}/auth/auth-code-error`)
|
|
}
|
|
```
|
|
**Source:** [Supabase Password-based Auth](https://supabase.com/docs/guides/auth/passwords)
|
|
|
|
### Pattern 6: Google OAuth Setup
|
|
**What:** Configure Google OAuth provider with proper redirect URLs
|
|
**When to use:** For social login (required in this phase)
|
|
|
|
**Setup Steps:**
|
|
1. **Google Cloud Console:**
|
|
- Create OAuth 2.0 Client ID (Web application)
|
|
- Add authorized JavaScript origins: `https://your-domain.com`
|
|
- Add authorized redirect URI: `https://<project-ref>.supabase.co/auth/v1/callback`
|
|
- For local dev: `http://localhost:3000/auth/v1/callback`
|
|
|
|
2. **Supabase Dashboard:**
|
|
- Navigate to Authentication > Providers > Google
|
|
- Enable Google provider
|
|
- Paste Client ID and Client Secret from Google
|
|
- Copy the Callback URL shown in dashboard
|
|
|
|
3. **Implementation:**
|
|
```typescript
|
|
// Login with Google (client component)
|
|
'use client'
|
|
|
|
import { createClient } from '@/lib/supabase/client'
|
|
|
|
export function GoogleSignInButton() {
|
|
const supabase = createClient()
|
|
|
|
async function handleGoogleSignIn() {
|
|
await supabase.auth.signInWithOAuth({
|
|
provider: 'google',
|
|
options: {
|
|
redirectTo: `${window.location.origin}/auth/callback`,
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<button onClick={handleGoogleSignIn}>
|
|
Accedi con Google
|
|
</button>
|
|
)
|
|
}
|
|
```
|
|
**Source:** [Supabase Login with Google](https://supabase.com/docs/guides/auth/social-login/auth-google)
|
|
|
|
### Anti-Patterns to Avoid
|
|
|
|
- **Using service_role key in client code:** Service role bypasses RLS - use only server-side, treat as secret
|
|
- **Storing JWT in localStorage:** Vulnerable to XSS - always use httpOnly cookies via `@supabase/ssr`
|
|
- **Skipping RLS policies:** 170+ apps were recently exposed by missing RLS (CVE-2025-48757)
|
|
- **Complex RLS policies without indexes:** Always index columns used in RLS WHERE clauses
|
|
- **Using user_metadata for tenant_id:** User can modify this client-side - use app_metadata only
|
|
- **Missing SELECT policies on INSERT:** PostgreSQL SELECTs newly inserted rows to return them - need both policies
|
|
- **Relying only on middleware for auth:** Verify auth at data access points (defense in depth)
|
|
|
|
## Don't Hand-Roll
|
|
|
|
Problems that look simple but have existing solutions:
|
|
|
|
| Problem | Don't Build | Use Instead | Why |
|
|
|---------|-------------|-------------|-----|
|
|
| Session management | Custom JWT signing/verification | `@supabase/ssr` cookie handling | Handles refresh tokens, PKCE flow, SSR edge cases automatically |
|
|
| Password hashing | bcrypt implementation | Supabase Auth | Uses Argon2 (more secure), handles salting, rate limiting built-in |
|
|
| Email verification | Custom token generation | Supabase email templates | Handles token expiry, one-time use, rate limiting, email delivery |
|
|
| OAuth flow | Implementing OAuth 2.0 | Supabase OAuth providers | Handles state params, PKCE, token exchange, security edge cases |
|
|
| Multi-tenant isolation | Application-level filtering | RLS policies | Database-level enforcement, prevents bugs from leaking data |
|
|
| Password reset | Token generation + email | Supabase password reset | Secure one-time tokens, prevents brute force, handles edge cases |
|
|
|
|
**Key insight:** Authentication has too many security edge cases to build from scratch. Supabase handles PKCE flow, token rotation, refresh logic, rate limiting, and OWASP compliance. Building custom auth means maintaining security patches forever.
|
|
|
|
## Common Pitfalls
|
|
|
|
### Pitfall 1: Missing RLS Policies (CRITICAL)
|
|
**What goes wrong:** Data leaks across tenants, complete database exposure via anon key
|
|
**Why it happens:** Supabase auto-generates REST APIs from schema, but RLS is opt-in not default
|
|
**How to avoid:**
|
|
- Enable RLS on EVERY table in public schema: `ALTER TABLE tablename ENABLE ROW LEVEL SECURITY;`
|
|
- Run Security Advisor in Supabase dashboard before production
|
|
- Never trust "it works" - test with multiple tenant accounts to verify isolation
|
|
**Warning signs:**
|
|
- Can see other users' data when testing with different accounts
|
|
- Security Advisor shows missing RLS warnings
|
|
- Empty results when querying from client (RLS blocks everything by default)
|
|
|
|
**Source:** [Supabase Security Flaw: 170+ Apps Exposed](https://byteiota.com/supabase-security-flaw-170-apps-exposed-by-missing-rls/)
|
|
|
|
### Pitfall 2: INSERT Fails with "new row violates policy"
|
|
**What goes wrong:** INSERT operations fail even though you have an INSERT policy
|
|
**Why it happens:** PostgreSQL SELECTs newly inserted rows to return them - needs SELECT policy too
|
|
**How to avoid:** Create both INSERT and SELECT policies for tables where users insert data
|
|
**Warning signs:** Error message "new row violates row-level security policy" on insert
|
|
|
|
**Example fix:**
|
|
```sql
|
|
-- Not enough - INSERT will fail
|
|
CREATE POLICY "Users can insert own posts" ON posts
|
|
FOR INSERT WITH CHECK (user_id = (SELECT auth.uid()));
|
|
|
|
-- Correct - need SELECT policy too
|
|
CREATE POLICY "Users can read own posts" ON posts
|
|
FOR SELECT USING (user_id = (SELECT auth.uid()));
|
|
|
|
CREATE POLICY "Users can insert own posts" ON posts
|
|
FOR INSERT WITH CHECK (user_id = (SELECT auth.uid()));
|
|
```
|
|
|
|
**Source:** [Supabase RLS Best Practices](https://www.leanware.co/insights/supabase-best-practices)
|
|
|
|
### Pitfall 3: Session Expires Without Middleware
|
|
**What goes wrong:** Users randomly logged out, authentication state inconsistent
|
|
**Why it happens:** Forgot to implement middleware session refresh - sessions expire
|
|
**How to avoid:** ALWAYS implement `middleware.ts` with `updateSession` helper from `@supabase/ssr`
|
|
**Warning signs:**
|
|
- Works initially, then users get logged out
|
|
- "session expired" errors in console
|
|
- Inconsistent auth state between pages
|
|
|
|
**Source:** [Next.js Supabase Cookie-Based Auth](https://the-shubham.medium.com/next-js-supabase-cookie-based-auth-workflow-the-best-auth-solution-2025-guide-f6738b4673c1)
|
|
|
|
### Pitfall 4: Slow RLS Policies (Performance)
|
|
**What goes wrong:** Queries take seconds instead of milliseconds, database overload
|
|
**Why it happens:** RLS policies with complex joins or missing indexes
|
|
**How to avoid:**
|
|
- Wrap `auth.uid()` in SELECT: `(SELECT auth.uid())` - 99% performance improvement
|
|
- Index all columns used in RLS policies: `CREATE INDEX idx_table_tenant_id ON table(tenant_id);`
|
|
- Use `IN` subqueries instead of joins in policies
|
|
- Always include explicit filters in queries even though RLS adds them implicitly
|
|
**Warning signs:**
|
|
- Queries taking >500ms in development
|
|
- High CPU usage on database
|
|
- `EXPLAIN ANALYZE` shows sequential scans on RLS columns
|
|
|
|
**Source:** [Supabase Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
|
|
|
|
### Pitfall 5: Using user_metadata for Tenant ID
|
|
**What goes wrong:** Users can modify their tenant_id, accessing other tenants' data
|
|
**Why it happens:** `user_metadata` is editable client-side, confused with `app_metadata`
|
|
**How to avoid:** ALWAYS use `app_metadata` for security-critical data like tenant_id
|
|
**Warning signs:**
|
|
- Storing tenant_id in metadata accessible from `user.user_metadata`
|
|
- Using service role to update `user_metadata` instead of `app_metadata`
|
|
|
|
**Correct approach:**
|
|
```typescript
|
|
// Setting tenant_id (server-side with service role)
|
|
const { data, error } = await supabaseAdmin.auth.admin.updateUserById(
|
|
userId,
|
|
{
|
|
app_metadata: { tenant_id: 'tenant-uuid' } // NOT user_metadata
|
|
}
|
|
)
|
|
|
|
// SQL function to access it
|
|
CREATE FUNCTION auth.tenant_id() RETURNS TEXT AS $$
|
|
SELECT ((current_setting('request.jwt.claims')::jsonb ->> 'app_metadata')::jsonb ->> 'tenant_id')::TEXT
|
|
$$ LANGUAGE SQL STABLE;
|
|
```
|
|
|
|
**Source:** [Supabase Multi Tenancy Guide](https://roughlywritten.substack.com/p/supabase-multi-tenancy-simple-and)
|
|
|
|
### Pitfall 6: Password Reset with Wrong Template
|
|
**What goes wrong:** Password reset emails use wrong template, verification fails
|
|
**Why it happens:** PKCE flow (SSR) requires different email template than implicit flow
|
|
**How to avoid:** Use "token_hash" template for reset-password emails, verify with `verifyOtp` not direct link
|
|
**Warning signs:**
|
|
- Reset link in email doesn't work
|
|
- "Invalid token" errors on password reset
|
|
- Reset flow works in development but fails in production
|
|
|
|
**Correct flow:**
|
|
1. Call `supabase.auth.resetPasswordForEmail()`
|
|
2. Email sent with token_hash template
|
|
3. User clicks link → redirects to your page with token_hash query param
|
|
4. Call `supabase.auth.verifyOtp({ token_hash, type: 'recovery' })`
|
|
5. If verified, call `supabase.auth.updateUser({ password: newPassword })`
|
|
|
|
**Source:** [Password Reset Flow in Next.js](https://medium.com/@sidharrthnix/next-js-authentication-with-supabase-and-nextauth-js-password-reset-recovery-part-3-of-3-0859f89a9ad1)
|
|
|
|
### Pitfall 7: Infinite Redirect Loops in Middleware
|
|
**What goes wrong:** App redirects endlessly between login and dashboard
|
|
**Why it happens:** Middleware redirects authenticated users away from login, but doesn't check if they're already on protected route
|
|
**How to avoid:** Check current path before redirecting in middleware
|
|
**Warning signs:**
|
|
- Browser shows "too many redirects" error
|
|
- Network tab shows repeated redirects
|
|
- App never loads
|
|
|
|
**Fix:**
|
|
```typescript
|
|
// middleware.ts - check path before redirecting
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
// Don't redirect if already on login page
|
|
if (!user && !request.nextUrl.pathname.startsWith('/login')) {
|
|
return NextResponse.redirect(new URL('/login', request.url))
|
|
}
|
|
|
|
// Don't redirect if already on dashboard
|
|
if (user && request.nextUrl.pathname === '/login') {
|
|
return NextResponse.redirect(new URL('/dashboard', request.url))
|
|
}
|
|
```
|
|
|
|
**Source:** [Next.js Middleware Authentication Pitfalls](https://www.hashbuilds.com/articles/next-js-middleware-authentication-protecting-routes-in-2025)
|
|
|
|
## Code Examples
|
|
|
|
Verified patterns from official sources:
|
|
|
|
### Email/Password Registration with Validation
|
|
```typescript
|
|
// app/actions/auth.ts
|
|
'use server'
|
|
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { redirect } from 'next/navigation'
|
|
import { z } from 'zod'
|
|
|
|
const registerSchema = z.object({
|
|
email: z.string().email('Email non valida'),
|
|
password: z.string()
|
|
.min(8, 'La password deve contenere almeno 8 caratteri')
|
|
.regex(/[0-9]/, 'La password deve contenere almeno un numero')
|
|
.regex(/[A-Z]/, 'La password deve contenere almeno una maiuscola'),
|
|
})
|
|
|
|
export async function registerUser(prevState: any, formData: FormData) {
|
|
const supabase = await createClient()
|
|
|
|
// Validate input
|
|
const parsed = registerSchema.safeParse({
|
|
email: formData.get('email'),
|
|
password: formData.get('password'),
|
|
})
|
|
|
|
if (!parsed.success) {
|
|
return {
|
|
error: parsed.error.flatten().fieldErrors,
|
|
success: false
|
|
}
|
|
}
|
|
|
|
// Register user
|
|
const { data, error } = await supabase.auth.signUp({
|
|
email: parsed.data.email,
|
|
password: parsed.data.password,
|
|
options: {
|
|
emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
|
|
}
|
|
})
|
|
|
|
if (error) {
|
|
// Specific error messages (user requirement)
|
|
if (error.message.includes('already registered')) {
|
|
return { error: 'Email già registrata', success: false }
|
|
}
|
|
return { error: error.message, success: false }
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Registrazione completata! Controlla la tua email per confermare l\'account.'
|
|
}
|
|
}
|
|
```
|
|
**Source:** [Supabase Password-based Auth](https://supabase.com/docs/guides/auth/passwords)
|
|
|
|
### Server Component Data Fetching with RLS
|
|
```typescript
|
|
// app/dashboard/page.tsx (Server Component)
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { redirect } from 'next/navigation'
|
|
|
|
export default async function DashboardPage() {
|
|
const supabase = await createClient()
|
|
|
|
// Get authenticated user
|
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
|
|
|
if (authError || !user) {
|
|
redirect('/login')
|
|
}
|
|
|
|
// Fetch user's data - RLS automatically filters by tenant_id
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select('*, plans(*)')
|
|
.eq('id', user.id)
|
|
.single()
|
|
|
|
return (
|
|
<div>
|
|
<h1>Benvenuto {profile.email}</h1>
|
|
<p>Piano: {profile.plans.display_name}</p>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
**Source:** [Supabase Auth with Next.js](https://supabase.com/docs/guides/auth/auth-helpers/nextjs)
|
|
|
|
### Login Form with Error Handling
|
|
```typescript
|
|
// app/(auth)/login/page.tsx
|
|
'use client'
|
|
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import { useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
|
|
export default function LoginPage() {
|
|
const [email, setEmail] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [error, setError] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const router = useRouter()
|
|
const supabase = createClient()
|
|
|
|
async function handleEmailLogin(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setLoading(true)
|
|
setError('')
|
|
|
|
const { data, error: signInError } = await supabase.auth.signInWithPassword({
|
|
email,
|
|
password,
|
|
})
|
|
|
|
if (signInError) {
|
|
// Specific error messages (user requirement)
|
|
if (signInError.message.includes('Invalid login credentials')) {
|
|
setError('Email o password errata')
|
|
} else if (signInError.message.includes('Email not confirmed')) {
|
|
setError('Conferma la tua email prima di accedere')
|
|
} else {
|
|
setError(signInError.message)
|
|
}
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
router.push('/dashboard')
|
|
router.refresh()
|
|
}
|
|
|
|
async function handleGoogleLogin() {
|
|
await supabase.auth.signInWithOAuth({
|
|
provider: 'google',
|
|
options: {
|
|
redirectTo: `${window.location.origin}/auth/callback`,
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<h1>Accedi</h1>
|
|
|
|
{error && <div className="error">{error}</div>}
|
|
|
|
<form onSubmit={handleEmailLogin}>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="Email"
|
|
required
|
|
/>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="Password"
|
|
required
|
|
/>
|
|
<button type="submit" disabled={loading}>
|
|
{loading ? 'Accesso...' : 'Accedi con Email'}
|
|
</button>
|
|
</form>
|
|
|
|
<button onClick={handleGoogleLogin}>
|
|
Accedi con Google
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
**Source:** [Supabase Auth Quickstart](https://supabase.com/docs/guides/auth/quickstarts/nextjs)
|
|
|
|
### Database Migration for Auth Tables
|
|
```sql
|
|
-- migrations/001_initial_auth_setup.sql
|
|
|
|
-- Plans table
|
|
CREATE TABLE plans (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name TEXT NOT NULL UNIQUE CHECK (name IN ('free', 'creator', 'pro')),
|
|
display_name TEXT NOT NULL,
|
|
price_monthly INTEGER NOT NULL CHECK (price_monthly >= 0),
|
|
features JSONB NOT NULL DEFAULT '{}',
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Insert default plans
|
|
INSERT INTO plans (name, display_name, price_monthly, features) VALUES
|
|
('free', 'Free', 0, '{
|
|
"posts_per_month": 10,
|
|
"ai_models": ["gpt-4o-mini"],
|
|
"social_accounts": 1,
|
|
"analytics": false,
|
|
"automation": false
|
|
}'),
|
|
('creator', 'Creator', 1900, '{
|
|
"posts_per_month": 50,
|
|
"ai_models": ["gpt-4o-mini", "gpt-4o", "claude-3-5-sonnet"],
|
|
"social_accounts": 3,
|
|
"analytics": true,
|
|
"automation": "manual"
|
|
}'),
|
|
('pro', 'Pro', 4900, '{
|
|
"posts_per_month": 200,
|
|
"ai_models": ["gpt-4o-mini", "gpt-4o", "claude-3-5-sonnet", "claude-opus-4"],
|
|
"social_accounts": 10,
|
|
"analytics": true,
|
|
"automation": "full"
|
|
}');
|
|
|
|
-- User profiles with tenant isolation
|
|
CREATE TABLE profiles (
|
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
tenant_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
plan_id UUID REFERENCES plans(id) NOT NULL DEFAULT (SELECT id FROM plans WHERE name = 'free'),
|
|
email TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Indexes for performance
|
|
CREATE INDEX idx_profiles_tenant_id ON profiles(tenant_id);
|
|
CREATE INDEX idx_profiles_plan_id ON profiles(plan_id);
|
|
|
|
-- Enable RLS
|
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE plans ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- RLS Policies
|
|
CREATE POLICY "Users can read own profile"
|
|
ON profiles FOR SELECT
|
|
USING ((SELECT auth.uid()) = id);
|
|
|
|
CREATE POLICY "Users can update own profile"
|
|
ON profiles FOR UPDATE
|
|
USING ((SELECT auth.uid()) = id)
|
|
WITH CHECK ((SELECT auth.uid()) = id);
|
|
|
|
CREATE POLICY "Everyone can read plans"
|
|
ON plans FOR SELECT
|
|
TO authenticated, anon
|
|
USING (true);
|
|
|
|
-- Function to create profile on signup (trigger)
|
|
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
INSERT INTO public.profiles (id, email, tenant_id)
|
|
VALUES (
|
|
NEW.id,
|
|
NEW.email,
|
|
gen_random_uuid() -- Each user gets unique tenant_id
|
|
);
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- Trigger on auth.users insert
|
|
CREATE TRIGGER on_auth_user_created
|
|
AFTER INSERT ON auth.users
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION public.handle_new_user();
|
|
|
|
-- Helper function to get current user's plan features
|
|
CREATE OR REPLACE FUNCTION public.get_user_plan_features()
|
|
RETURNS JSONB
|
|
LANGUAGE SQL STABLE
|
|
AS $$
|
|
SELECT features
|
|
FROM plans
|
|
WHERE id = (
|
|
SELECT plan_id
|
|
FROM profiles
|
|
WHERE id = (SELECT auth.uid())
|
|
);
|
|
$$;
|
|
```
|
|
**Source:** [Multi-tenant SaaS Database Schema](https://www.checklyhq.com/blog/building-a-multi-tenant-saas-data-model/)
|
|
|
|
### Checking Plan Limits in API Routes
|
|
```typescript
|
|
// app/api/posts/route.ts
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
|
|
export async function POST(request: Request) {
|
|
const supabase = await createClient()
|
|
|
|
// Get authenticated user
|
|
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
|
if (authError || !user) {
|
|
return NextResponse.json({ error: 'Non autenticato' }, { status: 401 })
|
|
}
|
|
|
|
// Get user's plan features
|
|
const { data: planFeatures, error: planError } = await supabase
|
|
.rpc('get_user_plan_features')
|
|
|
|
if (planError) {
|
|
return NextResponse.json({ error: 'Errore recupero piano' }, { status: 500 })
|
|
}
|
|
|
|
// Count posts this month
|
|
const { count, error: countError } = await supabase
|
|
.from('posts')
|
|
.select('*', { count: 'exact', head: true })
|
|
.eq('user_id', user.id)
|
|
.gte('created_at', new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString())
|
|
|
|
if (countError) {
|
|
return NextResponse.json({ error: 'Errore conteggio post' }, { status: 500 })
|
|
}
|
|
|
|
// Check limit
|
|
if (count >= planFeatures.posts_per_month) {
|
|
return NextResponse.json({
|
|
error: `Limite mensile raggiunto (${planFeatures.posts_per_month} post)`,
|
|
upgrade_required: true
|
|
}, { status: 403 })
|
|
}
|
|
|
|
// Proceed with creating post...
|
|
const body = await request.json()
|
|
const { data, error } = await supabase
|
|
.from('posts')
|
|
.insert({ ...body, user_id: user.id })
|
|
.select()
|
|
.single()
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json(data)
|
|
}
|
|
```
|
|
|
|
## State of the Art
|
|
|
|
| Old Approach | Current Approach | When Changed | Impact |
|
|
|--------------|------------------|--------------|--------|
|
|
| `@supabase/auth-helpers-nextjs` | `@supabase/ssr` | Nov 2023 | New package required for App Router, cookie handling improved |
|
|
| JWT in localStorage | HttpOnly cookies via SSR | 2023 | XSS protection, SSR support, better security |
|
|
| Client-side auth checks only | Middleware + RLS + app logic | 2024 | Defense in depth, prevents CVE-2025-48757 class vulnerabilities |
|
|
| Manual session refresh | Automatic via middleware | 2023 | Prevents session expiry bugs, better UX |
|
|
| Generic error messages | Specific error messages | 2025 UX trend | "Password errata" vs "Invalid credentials" - user requirement |
|
|
| Bare `auth.uid()` in RLS | `(SELECT auth.uid())` wrapped | 2024 | 99% performance improvement in benchmarks |
|
|
| `user_metadata` for tenant_id | `app_metadata` only | Security best practice | Prevents user tampering with tenant isolation |
|
|
| Stripe-style trial periods | Free tier as trial | SaaS trend 2025 | Lower friction, Supabase/Vercel model |
|
|
|
|
**Deprecated/outdated:**
|
|
- `@supabase/auth-helpers-nextjs`: Replaced by `@supabase/ssr`, doesn't work with App Router
|
|
- Pages Router auth patterns: App Router requires different client creation patterns
|
|
- `supabase.auth.session()`: Deprecated, use `supabase.auth.getSession()` or `getUser()`
|
|
- Manual PKCE implementation: `@supabase/ssr` handles this automatically
|
|
|
|
## Open Questions
|
|
|
|
Things that couldn't be fully resolved:
|
|
|
|
1. **New Device Email Notifications**
|
|
- What we know: User wants email when logging in from unrecognized device (security feature)
|
|
- What's unclear: Supabase doesn't have this built-in, needs custom implementation
|
|
- Recommendation: Implement in Phase 1 using database triggers or Edge Functions to track login IPs/user agents and send emails via Supabase Edge Functions + Resend/SendGrid
|
|
|
|
2. **Session Duration**
|
|
- What we know: Default is 3600 seconds (1 hour) access token, 604800 seconds (7 days) refresh token
|
|
- What's unclear: User preference for 30 days mentioned, but Supabase default is 7 days refresh
|
|
- Recommendation: Configure in Supabase dashboard: Authentication > Settings > JWT Expiry. Set refresh token to 2592000 seconds (30 days). Document this in plan.
|
|
|
|
3. **Multi-device Concurrent Sessions**
|
|
- What we know: User wants unlimited devices (likely)
|
|
- What's unclear: Supabase allows unlimited concurrent sessions by default, no limit needed
|
|
- Recommendation: No action required - works out of box. If "logout all devices" is needed, implement server action to revoke all sessions via `supabase.auth.admin.signOut(user.id, 'global')`
|
|
|
|
4. **Plan Upgrade Flow**
|
|
- What we know: Need to support Free → Creator → Pro upgrades
|
|
- What's unclear: Immediate activation vs billing cycle, proration, downgrade handling
|
|
- Recommendation: Defer payment integration to later phase, focus on plan switching in database (update `profiles.plan_id`). Document that payment/Stripe integration is Phase 2+.
|
|
|
|
## Sources
|
|
|
|
### Primary (HIGH confidence)
|
|
- [Supabase Server-Side Auth for Next.js](https://supabase.com/docs/guides/auth/server-side/nextjs) - Official setup guide
|
|
- [Supabase Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security) - Official RLS documentation
|
|
- [Supabase Login with Google](https://supabase.com/docs/guides/auth/social-login/auth-google) - OAuth setup
|
|
- [Supabase Auth Helpers Next.js](https://supabase.com/docs/guides/auth/auth-helpers/nextjs) - Migration guide and examples
|
|
|
|
### Secondary (MEDIUM confidence)
|
|
- [Next.js Supabase Cookie-Based Auth Workflow 2025](https://the-shubham.medium.com/next-js-supabase-cookie-based-auth-workflow-the-best-auth-solution-2025-guide-f6738b4673c1) - Verified against official docs
|
|
- [Supabase Multi Tenancy Guide](https://roughlywritten.substack.com/p/supabase-multi-tenancy-simple-and) - app_metadata pattern
|
|
- [Multi-tenant SaaS model with PostgreSQL](https://www.checklyhq.com/blog/building-a-multi-tenant-saas-data-model/) - Schema design patterns
|
|
- [Next.js Middleware Authentication 2025](https://www.hashbuilds.com/articles/next-js-middleware-authentication-protecting-routes-in-2025) - Middleware patterns
|
|
|
|
### Tertiary (LOW confidence - flagged for validation)
|
|
- [Supabase Best Practices](https://www.leanware.co/insights/supabase-best-practices) - WebSearch only
|
|
- [Supabase Security Flaw CVE-2025-48757](https://byteiota.com/supabase-security-flaw-170-apps-exposed-by-missing-rls/) - CVE details, verify with official Supabase security advisories
|
|
- [Password Reset Flow Next.js](https://medium.com/@sidharrthnix/next-js-authentication-with-supabase-and-nextauth-js-password-reset-recovery-part-3-of-3-0859f89a9ad1) - Community implementation, verify against official docs
|
|
|
|
## Metadata
|
|
|
|
**Confidence breakdown:**
|
|
- Standard stack: HIGH - Official Supabase documentation confirms @supabase/ssr is the current approach
|
|
- Architecture: HIGH - Verified with official docs, multiple sources agree on dual-client pattern
|
|
- RLS patterns: HIGH - Official PostgreSQL + Supabase docs, performance benchmarks verified
|
|
- Pitfalls: MEDIUM - Based on community experiences + official security advisories, some need validation
|
|
- Subscription schema: MEDIUM - Community patterns not official Supabase guidance, needs testing
|
|
|
|
**Research date:** 2026-01-31
|
|
**Valid until:** 2026-03-02 (30 days - stable ecosystem, Supabase updates quarterly)
|