A production-focused authentication block for Next.js 14+ SaaS apps using NextAuth.js v4, Supabase, bcryptjs, Zod, credentials auth, Google/GitHub OAuth, RBAC, email verification, password reset, and auth audit logs. This setup follows common authentication hardening guidance from OWASP and uses NextAuth page/API protection patterns with secure session handling expectations. [1][2][3]
This README is written for someone who wants to copy, paste, wire up, and launch this auth system inside a SaaS app with minimal confusion. The flow below takes you from a clean app to a working auth setup step by step. [2][1]
- Email/password login with hashed passwords using bcryptjs. [1]
- Google OAuth and GitHub OAuth via NextAuth providers. [2]
- Role-based access control with
user,admin, andsuper_admin. [3] - Email verification and password reset token flows. [1]
- Audit log table for auth activity tracking. [4]
- Next.js API and page protection helpers for authenticated and role-restricted routes. [2]
You should already have these ready before pasting the auth block:
- A Next.js 14+ app using the App Router.
- A Supabase project.
- A Google OAuth app if you want Google login.
- A GitHub OAuth app if you want GitHub login.
- An email sending setup for verification/reset emails, because this block generates tokens but does not send emails by itself. OWASP recommends careful handling of verification and recovery flows rather than exposing raw account state. [1]
Use this structure so the integration stays clean:
app/
api/
auth/
[...nextauth]/
route.ts
auth/
signin/
page.tsx
verify-email/
page.tsx
reset-password/
page.tsx
blocks/
auth.ts
lib/
email.ts
middleware.ts
.env.localYou can rename folders if you want, but if you keep the auth pages exactly as shown in the config, the paths will work immediately.
Install the packages used by the auth block:
npm install next-auth @supabase/supabase-js bcryptjs zodIf you plan to add request throttling, also install a rate-limiter package. OWASP recommends protection against brute-force and abuse on login and recovery endpoints. [1][3]
Example:
npm install @upstash/ratelimit @upstash/redisOpen your Supabase SQL Editor and run the SQL migration that comes with the auth block.
Use the hardened version of the schema, including these fields:
verify_token_expiresfailed_login_attemptslocked_untilemail_verify_tokenreset_tokenreset_token_expires
These fields support token expiry, recovery flows, and basic lockout behavior, which align with OWASP and ASVS expectations around authentication and account protection. [1][3]
After running the SQL, confirm the following tables exist:
profilesauth_events
Also confirm these are in place:
- RLS enabled on both tables.
- Policies for own-profile access.
- Policy for admin access.
updated_attrigger.- Failed login trigger if you included lockout logic.
Create a .env.local file in the root of your app.
Use this template:
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=replace-with-a-long-random-secret
NEXT_PUBLIC_SUPABASE_URL=your-supabase-url
SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secretSUPABASE_SERVICE_ROLE_KEYmust stay server-only because it bypasses RLS. [3]NEXTAUTH_SECRETshould be long and random because NextAuth uses it to protect session-related data. [2]NEXTAUTH_URLmust match your real site URL in production, or OAuth callbacks may fail. [2]
You can generate a secret with:
openssl rand -base64 32Create this file:
blocks/auth.tsPaste the hardened auth code into that file.
This file contains:
- NextAuth config
- Zod schemas
- registration helper
- email verification helper
- password reset helpers
- profile helpers
- RBAC helpers
- route protection wrapper
- audit log helpers
Create this file:
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth'
import { authOptions } from '@/blocks/auth'
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }This is the main route NextAuth uses for sign-in, callbacks, sessions, and provider flows. NextAuth documents page and API protection through its route/session model. [2]
Because the auth block adds custom fields like id, role, and emailVerified to session.user, create a type declaration file so TypeScript stops complaining.
Create:
types/next-auth.d.tsAdd:
import NextAuth, { DefaultSession } from 'next-auth'
declare module 'next-auth' {
interface Session {
user: {
id: string
role: 'user' | 'admin' | 'super_admin'
emailVerified: boolean
} & DefaultSession['user']
}
interface User {
role?: 'user' | 'admin' | 'super_admin'
emailVerified?: boolean
}
}
declare module 'next-auth/jwt' {
interface JWT {
id?: string
role?: 'user' | 'admin' | 'super_admin'
emailVerified?: boolean
}
}If you skip this step, your code may still run, but TypeScript will usually show errors when you access session.user.id or session.user.role.
Create middleware.ts in the project root:
import { withAuth } from 'next-auth/middleware'
export default withAuth({
pages: {
signIn: '/auth/signin',
},
})
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
}This protects routes like /dashboard and /admin at the middleware layer. NextAuth recommends this pattern for securing pages and API routes. [2]
If you only want certain routes protected, change the matcher array.
The auth config already points to this custom page:
/auth/signinSo create:
app/auth/signin/page.tsxAt minimum, this page should include:
- email input
- password input
- sign-in button
- Google sign-in button
- GitHub sign-in button
- forgot-password link
- link to sign-up page
Use NextAuth’s signIn() client helper on that page. Keep error messages generic for failed login attempts so you do not reveal whether an account exists. OWASP explicitly recommends avoiding account enumeration in auth flows. [1]
Create a sign-up page, for example:
app/auth/signup/page.tsxOn submit:
- Validate input using the
RegisterSchema. - Call
registerUser({ name, email, password })from the auth block. - Get back
userandverifyToken. - Send a verification email containing a URL like:
https://yourdomain.com/auth/verify-email?token=TOKEN_HERE- Show a success message like: “Check your email to verify your account.”
Do not auto-log the user in before verification unless you intentionally want that product behavior.
The auth block creates the token, but you need to send the email.
A simple email helper can look like this:
// lib/email.ts
export async function sendVerificationEmail(email: string, token: string) {
const url = `${process.env.NEXTAUTH_URL}/auth/verify-email?token=${token}`
// send email with your provider here
// resend / nodemailer / postmark / mailgun / ses
}Then, after registration:
const { user, verifyToken } = await registerUser({ name, email, password })
await sendVerificationEmail(user.email, verifyToken)OWASP recommends secure, time-limited verification flows rather than open-ended token handling. [1]
Create:
app/auth/verify-email/page.tsxIn this page:
- Read the
tokenfromsearchParams. - Call
verifyEmail(token). - Show success or failure UI.
Example server component logic:
import { verifyEmail } from '@/blocks/auth'
export default async function VerifyEmailPage({ searchParams }: { searchParams: { token?: string } }) {
const token = searchParams.token
const ok = token ? await verifyEmail(token) : false
return <div>{ok ? 'Email verified successfully' : 'Invalid or expired verification link'}</div>
}Create a page such as:
app/auth/forgot-password/page.tsxOn submit:
- Validate the email.
- Call
requestPasswordReset(email). - If the account exists, you will get a token.
- Send an email with a reset link.
- Always show a generic confirmation message like: “If an account exists, a reset link has been sent.”
That generic response helps avoid user enumeration, which OWASP recommends. [1]
Example reset URL:
https://yourdomain.com/auth/reset-password?token=TOKEN_HERECreate:
app/auth/reset-password/page.tsxOn that page:
- Read the token from the URL.
- Collect new password + confirm password.
- Validate on the client.
- Call
resetPassword(token, password)on submit. - Show success or expired/invalid-link state.
The hardened block uses time-limited reset tokens, which matches OWASP guidance for secure recovery flows. [1]
In a server component:
import { getServerSession } from 'next-auth'
import { authOptions, requireRole } from '@/blocks/auth'
export default async function AdminPage() {
const session = await getServerSession(authOptions)
requireRole(session, 'admin')
return <div>Admin only</div>
}Use requireRole(session, 'admin') or requireRole(session, 'super_admin') when you want role-based access control.
Example protected route:
import { withAuth } from '@/blocks/auth'
export const POST = withAuth(async (req, session) => {
return Response.json({
ok: true,
userId: session.user.id,
role: session.user.role,
})
}, 'admin')This ensures:
- unauthenticated users get
401 - authenticated but underprivileged users get
403 - authorized users reach the handler
Anywhere you need current-user info from the server:
import { getServerSession } from 'next-auth'
import { authOptions } from '@/blocks/auth'
const session = await getServerSession(authOptions)Useful fields available on session.user:
idemailnameimageroleemailVerified
This part is strongly recommended before production. OWASP and ASVS both emphasize defenses against brute-force and abuse on authentication endpoints. [1][3]
Recommended routes to rate limit:
- sign in
- sign up
- forgot password
- reset password
- resend verification email
If you use Upstash, wire it into the relevant route handlers or server actions. Even a simple IP-based limit is much better than none. [1]
Before deploying, test these flows manually:
- Register a new account.
- Verify email.
- Sign in with credentials.
- Sign out.
- Request password reset.
- Reset password with valid token.
- Confirm old password no longer works.
- Confirm new password works.
- Try signing in with wrong password multiple times.
- Try reset token after expiry.
- Try invalid verification token.
- Try visiting admin route as regular user.
- Try API route with no session.
- Confirm generic error behavior for invalid auth attempts.
OWASP recommends testing both normal and abuse cases in authentication and recovery flows. [1][3]
In GitHub Developer Settings:
- Set the callback URL to:
http://localhost:3000/api/auth/callback/githubFor production:
https://yourdomain.com/api/auth/callback/githubIn Google Cloud Console:
- Add authorized redirect URI:
http://localhost:3000/api/auth/callback/googleFor production:
https://yourdomain.com/api/auth/callback/googleIf these callback URLs are wrong, provider sign-in will fail. NextAuth’s provider setup depends on matching callback configuration. [2]
Before going live, confirm all of this:
NEXTAUTH_URLpoints to your real domain. [2]NEXTAUTH_SECRETis set. [2]SUPABASE_SERVICE_ROLE_KEYis present only on the server. [3]- OAuth provider production callback URLs are correct. [2]
- Verification and reset emails use your real domain.
- Cookies are secure in production.
- Rate limiting is active.
- RLS is enabled in Supabase.
- Protected routes actually reject unauthorized users.
- Password reset and verification links expire correctly.
import { getProfile } from '@/blocks/auth'
const profile = await getProfile(session.user.id)import { updateProfile } from '@/blocks/auth'
await updateProfile(session.user.id, {
name: 'Samarth',
avatar_url: 'https://example.com/avatar.png',
})import { changePassword } from '@/blocks/auth'
await changePassword(session.user.id, currentPassword, newPassword)import { getAuthEvents } from '@/blocks/auth'
const events = await getAuthEvents(session.user.id)This auth block handles the auth logic, but these pieces are still product-specific and should be added per SaaS:
- Email sending provider integration
- onboarding flow after signup
- billing/subscription checks
- team/invite logic if your SaaS is multi-user
- MFA if you want stronger account security
- bot protection or captcha if abuse becomes a concern
You probably forgot the types/next-auth.d.ts file.
Usually one of these is wrong:
NEXTAUTH_URL- provider callback URL
- client ID / client secret
Check:
- SQL migration fully ran
- table exists
- policy exists
- service role key is valid
- server-side code is using the admin client
Possible causes:
- token expired
- token already used
- wrong environment URL in email
- token not included correctly in query string
Check:
requireRole(session, 'admin')is actually called- route uses
withAuth(..., 'admin') - session token contains role
- JWT callback is populating
token.role
This system is strong, but copy-pasting auth does not automatically make an app secure. OWASP advises secure design for login, recovery, authorization, and abuse prevention as a whole, and ASVS treats authentication, session management, configuration, and access control as separate verification areas. [1][3][5]
That means you should still review:
- access control on every sensitive route. [5]
- secure environment variable handling. [3]
- rate limiting and lockout behavior. [1][3]
- audit logging for important auth actions. [4][3]
- production cookie/session behavior. [2][3]
If you want the shortest version, do these in order:
- Install dependencies.
- Run the Supabase SQL migration.
- Add environment variables.
- Paste the auth block into
blocks/auth.ts. - Create
app/api/auth/[...nextauth]/route.ts. - Add
types/next-auth.d.ts. - Add
middleware.ts. - Build
/auth/signin,/auth/signup,/auth/verify-email,/auth/forgot-password, and/auth/reset-password. - Add email sending.
- Test every auth flow locally.
- Configure OAuth callbacks.
- Deploy with production env vars.
If you are distributing this to SaaS founders, include this note with the block:
This auth block provides the backend auth foundation, but you are still responsible for email delivery, frontend pages, production environment configuration, and route-level authorization inside your app. Authentication security depends on correct deployment and correct use of protected routes. [1][3][2]