DEV Community

Cover image for Securing Next.js + Supabase After Switching to NextAuth
Ali Zaib
Ali Zaib

Posted on

Securing Next.js + Supabase After Switching to NextAuth

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
Enter fullscreen mode Exit fullscreen mode

Why? Because:

  1. Your Supabase keys are exposed in the frontend bundle
  2. No RLS means no access control
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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' })
}
Enter fullscreen mode Exit fullscreen mode

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.co from browser

Quick Test

Open your browser DevTools:

  1. Go to Network tab
  2. Perform data operations
  3. If you see requests to *.supabase.co from 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)
Enter fullscreen mode Exit fullscreen mode

2. Using Anon Key Instead of Service Role

// ❌ Limited permissions
SUPABASE_ANON_KEY

// ✅ Full access (server-side only!)
SUPABASE_SERVICE_ROLE_KEY
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Performance Tips

The API layer adds ~50ms latency, but you can optimize:

  1. Use SWR for caching:
import useSWR from 'swr'

function Component() {
  const { data } = useSWR('/api/get-data', fetcher)
  return <div>{data}</div>
}
Enter fullscreen mode Exit fullscreen mode
  1. Add database indexes:
CREATE INDEX idx_items_user_id ON items(user_id);
Enter fullscreen mode Exit fullscreen mode

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

  1. Supabase Pro - Just pay for custom domains (~$25/mo)
  2. Custom JWT Integration - Complex but allows direct frontend calls
  3. 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)