# MarrowStack Auth System
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]
## What this block includes
- 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`, and `super_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]
## Before you start
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]
## Recommended folder structure
Use this structure so the integration stays clean:
```txt
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.local
```
You can rename folders if you want, but if you keep the auth pages exactly as shown in the config, the paths will work immediately.
## Step 1: Install dependencies
Install the packages used by the auth block:
```bash
npm install next-auth @supabase/supabase-js bcryptjs zod
```
If 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:
```bash
npm install @upstash/ratelimit @upstash/redis
```
## Step 2: Create Supabase tables
Open 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_expires`
- `failed_login_attempts`
- `locked_until`
- `email_verify_token`
- `reset_token`
- `reset_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:
- `profiles`
- `auth_events`
Also confirm these are in place:
- RLS enabled on both tables.
- Policies for own-profile access.
- Policy for admin access.
- `updated_at` trigger.
- Failed login trigger if you included lockout logic.
## Step 3: Add environment variables
Create a `.env.local` file in the root of your app.
Use this template:
```env
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-secret
```
### Important notes
- `SUPABASE_SERVICE_ROLE_KEY` must stay **server-only** because it bypasses RLS. [3]
- `NEXTAUTH_SECRET` should be long and random because NextAuth uses it to protect session-related data. [2]
- `NEXTAUTH_URL` must match your real site URL in production, or OAuth callbacks may fail. [2]
You can generate a secret with:
```bash
openssl rand -base64 32
```
## Step 4: Add the auth block file
Create this file:
```txt
blocks/auth.ts
```
Paste 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
## Step 5: Create the NextAuth route
Create this file:
```ts
// 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]
## Step 6: Extend NextAuth types
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:
```txt
types/next-auth.d.ts
```
Add:
```ts
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`.
## Step 7: Add middleware protection
Create `middleware.ts` in the project root:
```ts
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.
## Step 8: Build your sign-in page
The auth config already points to this custom page:
```txt
/auth/signin
```
So create:
```txt
app/auth/signin/page.tsx
```
At 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]
## Step 9: Build your sign-up flow
Create a sign-up page, for example:
```txt
app/auth/signup/page.tsx
```
On submit:
1. Validate input using the `RegisterSchema`.
2. Call `registerUser({ name, email, password })` from the auth block.
3. Get back `user` and `verifyToken`.
4. Send a verification email containing a URL like:
```txt
https://yourdomain.com/auth/verify-email?token=TOKEN_HERE
```
5. 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.
## Step 10: Send verification emails
The auth block creates the token, but **you** need to send the email.
A simple email helper can look like this:
```ts
// 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:
```ts
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]
## Step 11: Create the email verification page
Create:
```txt
app/auth/verify-email/page.tsx
```
In this page:
1. Read the `token` from `searchParams`.
2. Call `verifyEmail(token)`.
3. Show success or failure UI.
Example server component logic:
```ts
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>
}
```
## Step 12: Create forgot-password flow
Create a page such as:
```txt
app/auth/forgot-password/page.tsx
```
On submit:
1. Validate the email.
2. Call `requestPasswordReset(email)`.
3. If the account exists, you will get a token.
4. Send an email with a reset link.
5. 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:
```txt
https://yourdomain.com/auth/reset-password?token=TOKEN_HERE
```
## Step 13: Create reset-password page
Create:
```txt
app/auth/reset-password/page.tsx
```
On that page:
1. Read the token from the URL.
2. Collect new password + confirm password.
3. Validate on the client.
4. Call `resetPassword(token, password)` on submit.
5. Show success or expired/invalid-link state.
The hardened block uses time-limited reset tokens, which matches OWASP guidance for secure recovery flows. [1]
## Step 14: Protect server components
In a server component:
```ts
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.
## Step 15: Protect API routes
Example protected route:
```ts
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
## Step 16: Use session data in your app
Anywhere you need current-user info from the server:
```ts
import { getServerSession } from 'next-auth'
import { authOptions } from '@/blocks/auth'
const session = await getServerSession(authOptions)
```
Useful fields available on `session.user`:
- `id`
- `email`
- `name`
- `image`
- `role`
- `emailVerified`
## Step 17: Add rate limiting
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]
## Step 18: Test locally
Before deploying, test these flows manually:
### Core auth tests
- 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.
### Security tests
- 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]
## Step 19: Configure OAuth providers
### GitHub OAuth
In GitHub Developer Settings:
- Set the callback URL to:
```txt
http://localhost:3000/api/auth/callback/github
```
For production:
```txt
https://yourdomain.com/api/auth/callback/github
```
### Google OAuth
In Google Cloud Console:
- Add authorized redirect URI:
```txt
http://localhost:3000/api/auth/callback/google
```
For production:
```txt
https://yourdomain.com/api/auth/callback/google
```
If these callback URLs are wrong, provider sign-in will fail. NextAuth’s provider setup depends on matching callback configuration. [2]
## Step 20: Production deployment checklist
Before going live, confirm all of this:
- `NEXTAUTH_URL` points to your real domain. [2]
- `NEXTAUTH_SECRET` is set. [2]
- `SUPABASE_SERVICE_ROLE_KEY` is 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.
## Common integration examples
### Get current profile
```ts
import { getProfile } from '@/blocks/auth'
const profile = await getProfile(session.user.id)
```
### Update profile
```ts
import { updateProfile } from '@/blocks/auth'
await updateProfile(session.user.id, {
name: 'Samarth',
avatar_url: 'https://example.com/avatar.png',
})
```
### Change password
```ts
import { changePassword } from '@/blocks/auth'
await changePassword(session.user.id, currentPassword, newPassword)
```
### Get audit events
```ts
import { getAuthEvents } from '@/blocks/auth'
const events = await getAuthEvents(session.user.id)
```
## What you still need to build yourself
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
## Troubleshooting
### `session.user.id` type error
You probably forgot the `types/next-auth.d.ts` file.
### OAuth login fails after redirect
Usually one of these is wrong:
- `NEXTAUTH_URL`
- provider callback URL
- client ID / client secret
### Supabase permission error
Check:
- SQL migration fully ran
- table exists
- policy exists
- service role key is valid
- server-side code is using the admin client
### Password reset link says invalid
Possible causes:
- token expired
- token already used
- wrong environment URL in email
- token not included correctly in query string
### Admin route is accessible by normal user
Check:
- `requireRole(session, 'admin')` is actually called
- route uses `withAuth(..., 'admin')`
- session token contains role
- JWT callback is populating `token.role`
## Security notes for SaaS owners
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]
## Quick start summary
If you want the shortest version, do these in order:
1. Install dependencies.
2. Run the Supabase SQL migration.
3. Add environment variables.
4. Paste the auth block into `blocks/auth.ts`.
5. Create `app/api/auth/[...nextauth]/route.ts`.
6. Add `types/next-auth.d.ts`.
7. Add `middleware.ts`.
8. Build `/auth/signin`, `/auth/signup`, `/auth/verify-email`, `/auth/forgot-password`, and `/auth/reset-password`.
9. Add email sending.
10. Test every auth flow locally.
11. Configure OAuth callbacks.
12. Deploy with production env vars.
## Suggested buyer note
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]