Securing Next.js + Supabase After Switching to NextAuth
The Problem
I was using Supabase Auth with Google OAuth, but users saw "supabase.co" on the consent screen instead of my app's branding. Custom domains require a paid Supabase plan, so I switched to NextAuth.js.
But here's the catch: Removing Supabase Auth also removed Row Level Security (RLS), which created major security vulnerabilities.
The Security Risk
Without proper implementation, your Supabase database becomes wide open:
// ❌ Anyone can grab your keys from the browser and do this:
const supabase = createClient('YOUR_URL', 'EXPOSED_ANON_KEY')
await supabase.from('users').select('*') // Read everything
await supabase.from('orders').delete() // Delete everything
Why? Because:
- Your Supabase keys are exposed in the frontend bundle
- No RLS means no access control
- Client-side checks can be bypassed
The Secure Solution
All database operations must go through Next.js API routes with proper session validation.
Architecture
Browser → Next.js API Routes → Supabase
↓
Session Check
Authorization
Step 1: Environment Variables
# .env.local (server-side only)
NEXTAUTH_URL=https://yourdomain.com
NEXTAUTH_SECRET=your-secret
GOOGLE_CLIENT_ID=your-google-id
GOOGLE_CLIENT_SECRET=your-google-secret
# Use SERVICE_ROLE key, not anon key!
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
Step 2: NextAuth Configuration
// pages/api/auth/[...nextauth].js
import NextAuth from "next-auth"
import GoogleProvider from "next-auth/providers/google"
export const authOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
callbacks: {
async session({ session, token }) {
session.user.id = token.sub
return session
},
},
}
export default NextAuth(authOptions)
Step 3: Secure API Routes
// pages/api/get-data.js
import { getServerSession } from "next-auth/next"
import { authOptions } from "./auth/[...nextauth]"
import { createClient } from '@supabase/supabase-js'
export default async function handler(req, res) {
// 1. Verify authentication
const session = await getServerSession(req, res, authOptions)
if (!session) {
return res.status(401).json({ error: 'Unauthorized' })
}
// 2. Create Supabase client (server-side only)
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY // Never expose this!
)
// 3. Query with proper authorization
const { data, error } = await supabase
.from('items')
.select('*')
.eq('user_id', session.user.id) // Critical: filter by user
if (error) {
return res.status(500).json({ error: error.message })
}
return res.status(200).json({ data })
}
Step 4: Client-Side Calls
// ❌ NEVER do this in client components
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(url, key) // Exposes keys!
// ✅ ALWAYS call your API routes instead
async function fetchData() {
const response = await fetch('/api/get-data')
const { data } = await response.json()
return data
}
Complete CRUD Example
// pages/api/items/[id].js
import { getServerSession } from "next-auth/next"
import { authOptions } from "../auth/[...nextauth]"
import { createClient } from '@supabase/supabase-js'
export default async function handler(req, res) {
const session = await getServerSession(req, res, authOptions)
if (!session) return res.status(401).json({ error: 'Unauthorized' })
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY
)
const { id } = req.query
// GET
if (req.method === 'GET') {
const { data, error } = await supabase
.from('items')
.select('*')
.eq('id', id)
.eq('user_id', session.user.id)
.single()
if (error) return res.status(404).json({ error: 'Not found' })
return res.status(200).json({ data })
}
// PUT
if (req.method === 'PUT') {
const { title, description } = req.body
const { data, error } = await supabase
.from('items')
.update({ title, description })
.eq('id', id)
.eq('user_id', session.user.id) // Verify ownership
if (error) return res.status(500).json({ error: error.message })
return res.status(200).json({ data })
}
// DELETE
if (req.method === 'DELETE') {
const { error } = await supabase
.from('items')
.delete()
.eq('id', id)
.eq('user_id', session.user.id) // Verify ownership
if (error) return res.status(500).json({ error: error.message })
return res.status(200).json({ success: true })
}
return res.status(405).json({ error: 'Method not allowed' })
}
Security Checklist
Before deploying, verify:
- [ ] No Supabase imports in client-side code
- [ ] All data operations through API routes
- [ ] Every API route validates session
- [ ] All queries filter by
session.user.id - [ ] Service role key only in server environment variables
- [ ] Open DevTools → No requests to
*.supabase.cofrom browser
Quick Test
Open your browser DevTools:
- Go to Network tab
- Perform data operations
- If you see requests to
*.supabase.cofrom the browser, you have a problem!
All requests should go to your /api/* routes.
Common Mistakes
1. Forgetting to Filter by User ID
// ❌ Returns ALL users' data
const { data } = await supabase.from('items').select('*')
// ✅ Returns only current user's data
const { data } = await supabase
.from('items')
.select('*')
.eq('user_id', session.user.id)
2. Using Anon Key Instead of Service Role
// ❌ Limited permissions
SUPABASE_ANON_KEY
// ✅ Full access (server-side only!)
SUPABASE_SERVICE_ROLE_KEY
3. Skipping Session Check
// ❌ No authentication
export default async function handler(req, res) {
const { data } = await supabase.from('items').select('*')
return res.json({ data })
}
// ✅ Always verify session
export default async function handler(req, res) {
const session = await getServerSession(req, res, authOptions)
if (!session) return res.status(401).json({ error: 'Unauthorized' })
// ... rest of code
}
Additional Security Layers
Rate Limiting
import rateLimit from 'express-rate-limit'
export const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per window
})
Input Validation
import { z } from 'zod'
const schema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(1000),
})
// In your API route:
const validated = schema.parse(req.body)
Performance Tips
The API layer adds ~50ms latency, but you can optimize:
- Use SWR for caching:
import useSWR from 'swr'
function Component() {
const { data } = useSWR('/api/get-data', fetcher)
return <div>{data}</div>
}
- Add database indexes:
CREATE INDEX idx_items_user_id ON items(user_id);
The Tradeoff
What you gain:
- ✅ Custom branding on OAuth
- ✅ Full control over auth flow
- ✅ No vendor lock-in
- ✅ Actual security
What you lose:
- ❌ Automatic RLS convenience
- ❌ Built-in Supabase session management
- ❌ Direct client-to-DB queries
- ❌ Some Supabase auth features
When to Use This Approach
Good for:
- Early-stage products where branding matters
- Apps on tight budgets
- Teams that want full auth control
Not ideal for:
- Enterprise apps heavily using Supabase auth features
- Teams that prefer less backend code
- Projects where Supabase Pro is already affordable
Alternatives to Consider
- Supabase Pro - Just pay for custom domains (~$25/mo)
- Custom JWT Integration - Complex but allows direct frontend calls
- Custom SMTP - Fixes email branding (not OAuth)
Conclusion
Client-side security isn't real security. When you remove Supabase Auth, you must implement proper backend authorization. The extra code is worth it for the peace of mind.
Key principle: Never trust the frontend. Always verify on the server.
Live example: I've been running this architecture on ToolsHubKit.com for months without issues.
Questions? Drop a comment below! Happy to review your implementation.
Resources
Found this helpful? Give it a ❤️ and follow for more Next.js security tips!
Top comments (0)