Skip to main content

Authentication

Design Philosophy

  • JWT over sessions: Stateless, Edge-compatible, no DB query per request
  • bcrypt for passwords: One-way hash, intentionally slow (brute-force resistant)
  • Defense in depth: Multiple validation layers + rate limiting
  • Guest checkout supported: Lower friction, encourage signup without forcing
  • OAuth integration: Google SSO with automatic account creation

Architecture

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│ Browser │────▶│ NextAuth │────▶│ PostgreSQL │
│ Cookie │ │ (Auth.js) │ │ (users) │
│ httpOnly │◀────│ │◀────│ │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ ▼
│ ┌─────────────┐
│ │ Google │ ← OAuth Provider
│ │ OAuth │
│ └─────────────┘

┌─────────────┐
│ Middleware │
│ (Edge) │ ← JWT verified here, no DB needed
└─────────────┘

Core Configuration

File: src/auth.ts

import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import bcrypt from "bcryptjs";

export const { handlers, signIn, signOut, auth } = NextAuth({
secret: process.env.AUTH_SECRET,

// Suppress noisy credential errors in logs
logger: {
error(error) {
if (error.name !== "CredentialsSignin") {
console.error("[Auth Error]", error);
}
},
warn(code) {
console.warn("[Auth Warning]", code);
},
debug(code, metadata) {
/* silent */
},
},

providers: [
// Google OAuth
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),

// Email/Password
Credentials({
async authorize(credentials) {
const email = String(credentials?.email || "");
const password = String(credentials?.password || "");

if (!email || !password) return null;

const result = await query(
"SELECT id, email, password_hash, role FROM users WHERE email = $1",
[email]
);

if (result.rows.length === 0) return null;

const user = result.rows[0];

// OAuth users cannot login with password
if (!user.password_hash) return null;

const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) return null;

return {
id: user.id.toString(),
email: user.email,
role: user.role,
};
},
}),
],

callbacks: {
async jwt({ token, user, account }) {
// Credentials login
if (user) {
token.id = user.id;
token.role = user.role;
}

// Google OAuth: Auto-create or link user
if (account?.provider === "google") {
const email = token.email;
if (!email) return token;

// Upsert: Create new user or get existing
const res = await query(
`
INSERT INTO users (email, password_hash, role)
VALUES ($1, NULL, 'customer')
ON CONFLICT (email)
DO UPDATE SET email = EXCLUDED.email
RETURNING id, role
`,
[email]
);

token.id = res.rows[0].id.toString();
token.role = res.rows[0].role;
}

return token;
},

async session({ session, token }) {
session.user.id = token.id as string;
session.user.role = token.role as string;
return session;
},
},

session: { strategy: "jwt" },
pages: { signIn: "/auth/signin" },
});

Authentication Flows

1. Credentials Login

User submits email/password


┌───────────────────────────────┐
│ Query: SELECT ... WHERE email │
└───────────────┬───────────────┘

┌───────┴───────┐
│ │
Not found Found user
│ │
▼ ▼
return null ┌─────────────────┐
│ password_hash │
│ exists? │
└────────┬────────┘

┌────────┴────────┐
│ │
NULL (OAuth) Has hash
│ │
▼ ▼
return null bcrypt.compare()

┌───────┴───────┐
│ │
Invalid Valid
│ │
▼ ▼
return null return { id, email, role }


JWT created & stored

2. Google OAuth Login

User clicks "Sign in with Google"


┌─────────────────────────┐
│ Google OAuth Flow │
│ (consent screen) │
└───────────┬─────────────┘


┌─────────────────────────────────────────┐
│ jwt callback: account.provider = google │
└───────────────────┬─────────────────────┘


┌─────────────────────────────────────────┐
│ UPSERT into users table │
│ INSERT ... ON CONFLICT (email) │
│ DO UPDATE SET email = EXCLUDED.email │
│ RETURNING id, role │
└───────────────────┬─────────────────────┘

┌───────┴───────┐
│ │
New user Existing user
created (linked)
│ │
└───────┬───────┘


JWT with id, role

Key Point: OAuth users have password_hash = NULL, preventing credential login attempts.


Password Security

Registration

// Generate hash (~100ms intentionally slow)
const passwordHash = await bcrypt.hash(password, 10);
// 10 = salt rounds (cost factor)

await query("INSERT INTO users (email, password_hash) VALUES ($1, $2)", [
email,
passwordHash,
]);

Login Verification

// bcrypt.compare does NOT decrypt
// It re-hashes input with same salt and compares
const isValid = await bcrypt.compare(inputPassword, storedHash);

Why bcrypt is Secure

PropertyBenefit
One-wayCannot reverse hash to get password
SaltedSame password → different hash each time
Slow (~100ms)Defeats brute-force attacks
Cost factorCan increase rounds as hardware improves

Password Reset Flow

Architecture

┌──────────┐    ┌──────────────┐    ┌─────────┐    ┌─────────┐
│ Client │───▶│ forgot-pwd │───▶│ Redis │ │ DB │
│ │ │ API │ │ (rate │ │ (token) │
└──────────┘ └──────┬───────┘ │ limit) │ └────┬────┘
│ └─────────┘ │
▼ │
┌─────────────┐ │
│ Resend │ │
│ (email) │ │
└─────────────┘ │

┌──────────┐ ┌──────────────┐ │
│ Client │───▶│ reset-pwd │───────────────────────┘
│ (token) │ │ API │
└──────────┘ └──────────────┘

Step 1: Request Reset (/api/auth/forgot-password)

// 1. Generate secure token
const token = crypto.randomBytes(32).toString("hex");
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");

// 2. Upsert token (one active token per user)
await query(
`
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, used)
VALUES ($1, $2, NOW() + INTERVAL '1 hour', false)
ON CONFLICT (user_id)
DO UPDATE SET token_hash = EXCLUDED.token_hash,
expires_at = NOW() + INTERVAL '1 hour',
used = false
`,
[user.id, tokenHash]
);

// 3. Send email with plain token (NOT hash)
const resetUrl = `${baseUrl}/auth/reset-password?token=${token}`;
await sendPasswordResetEmail(email, resetUrl);

Step 2: Reset Password (/api/auth/reset-password)

// 1. Hash the token from URL
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");

// 2. Atomic token consumption (transaction)
await client.query("BEGIN");

const consume = await client.query(
`
UPDATE password_reset_tokens
SET used = TRUE
WHERE token_hash = $1
AND used = FALSE
AND expires_at > NOW()
RETURNING user_id
`,
[tokenHash]
);

if (consume.rows.length === 0) {
await client.query("ROLLBACK");
return { error: "Invalid or expired reset link" };
}

// 3. Update password
const passwordHash = await bcrypt.hash(newPassword, 10);
await client.query("UPDATE users SET password_hash = $1 WHERE id = $2", [
passwordHash,
consume.rows[0].user_id,
]);

await client.query("COMMIT");

Security Features

FeatureImplementation
Token hashingOnly hash stored in DB, plain token in URL
One-time useused = TRUE on consumption
Expirationexpires_at > NOW() check
Atomic consumptionTransaction prevents race conditions
Email enumeration preventionAlways return same message

Rate Limiting

Implementation Pattern

// Fixed-window rate limiting with Redis
async function rateLimitFixedWindow(
key: string,
limit: number,
windowSeconds: number
): Promise<boolean> {
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
return count <= limit;
}

Rate Limits by Endpoint

EndpointLimitWindowKey
Register
Per IP5 requests10 minrl:register:ip:{ip}
Per email1 request10 minrl:register:email:{email}
Forgot Password
Per IP10 requests5 minrl:forgot:ip:{ip}
Per email3 requests15 minrl:forgot:email:{email}
Cooldown1 request60 seccooldown:forgot:email:{email}
Reset Password
Per IP10 requests15 minrl:reset:ip:{ip}
Per token5 attempts15 minrl:reset:tokenhash:{hash}
Change Password
Per user3 attempts15 minrl:change-password:user:{id}

Middleware Protection

File: src/middleware.ts

import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";

export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;

// Dynamic cookie name based on protocol
const isSecure = req.url.startsWith("https");
const cookieName = isSecure
? "__Secure-authjs.session-token"
: "authjs.session-token";

const token = await getToken({
req,
secret: process.env.AUTH_SECRET,
cookieName,
});

const protectedRoutes = ["/orders", "/profile", "/settings"];
const isProtectedRoute = protectedRoutes.some((r) => pathname.startsWith(r));
const isAdminRoute = pathname.startsWith("/admin");

if (isProtectedRoute || isAdminRoute) {
if (!token) {
const signInUrl = new URL("/auth/signin", req.url);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);
}

if (isAdminRoute && token.role !== "admin") {
return NextResponse.redirect(new URL("/", req.url));
}
}

return NextResponse.next();
}

export const config = {
matcher: [
"/orders/:path*",
"/profile/:path*",
"/settings/:path*",
"/admin/:path*",
"/api/admin/:path*",
],
};
EnvironmentProtocolCookie Name
ProductionHTTPS__Secure-authjs.session-token
DevelopmentHTTPauthjs.session-token

Role-Based Access Control

Database Schema

ALTER TABLE users
ADD COLUMN role TEXT DEFAULT 'customer'
CHECK (role IN ('customer', 'admin'));

Access Matrix

RouteGuestCustomerAdmin
/products
/cart
Checkout✅ (email required)
/orders❌ → Login
/profile❌ → Login
/settings❌ → Login
/admin/*❌ → Login❌ → Home
/api/admin/*❌ 401❌ 403

JWT Security

Token Contents

{
"id": "1",
"email": "user@example.com",
"role": "customer",
"iat": 1701792000,
"exp": 1704384000
}

Why JWT is Safe

Attacker modifies: { "role": "admin" }


Server verifies signature


MISMATCH → Request rejected ❌
PropertySecurity Benefit
SignedTampering detected via HMAC
httpOnly cookieNot accessible via JavaScript (XSS protection)
Server secretOnly server can create valid tokens
ExpirationLimits window of compromise

Type Extensions

File: src/types/next-auth.d.ts

declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession["user"];
}

interface User {
id: string;
role: string;
}
}

declare module "next-auth/jwt" {
interface JWT {
id: string;
role: string;
}
}

Guest vs Authenticated Checkout

// Cart page determines email source
const emailToUse = session?.user?.email || guestEmail;

// Checkout API associates user if logged in
const userId = session?.user?.id ? parseInt(session.user.id) : null;

await query(
"INSERT INTO orders (email, user_id, ...) VALUES ($1, $2, ...)",
[emailToUse, userId] // userId is NULL for guests
);
Checkout Typeuser_idEmail SourceVisible in "My Orders"
GuestNULLForm input
AuthenticatedUser IDSession

Security Checklist

  • Passwords hashed with bcrypt (cost factor 10)
  • JWT stored in httpOnly cookie
  • CSRF protection via NextAuth
  • Rate limiting on all auth endpoints
  • Email enumeration prevention
  • One-time password reset tokens
  • Token expiration (1 hour)
  • OAuth users cannot use password login
  • Role-based access control
  • Secure cookie names in production