Production-ready security practices for authentication
Authentication security is critical for protecting user data and preventing unauthorized access. This guide covers essential security practices for production deployments.
The AUTH_SECRET is the foundation of your security. It must be:
# Generate a secure secret (32+ bytes)
openssl rand -base64 32
# Output example:
# h8vF2xK9mN5pQ7rT3wY6zA1bC4dE8fG2hJ5kL7mN9pQ=
# Store in .env.local (development)
AUTH_SECRET=h8vF2xK9mN5pQ7rT3wY6zA1bC4dE8fG2hJ5kL7mN9pQ=
# Use environment variables (production)
# Set in Vercel, AWS, etc. - never hardcodeAUTH_SECRET is compromised, attackers can forge session tokens and impersonate any user. Rotate secrets immediately if exposure is suspected.Implement secret rotation for added security:
Support old and new secrets during transition
// Support multiple secrets during rotation
const secrets = [
process.env.AUTH_SECRET!, // Current secret
process.env.AUTH_SECRET_OLD, // Previous secret (grace period)
].filter(Boolean);
// Try each secret when verifying tokens
async function verifyToken(token: string) {
for (const secret of secrets) {
try {
const payload = await verify(token, secret);
return payload;
} catch (err) {
continue; // Try next secret
}
}
throw new Error("Invalid token");
}
// Always sign with current secret
const token = await sign(payload, process.env.AUTH_SECRET!);Always use HTTPS in production to prevent man-in-the-middle attacks:
// Next.js middleware/proxy
export function proxy(request: NextRequest) {
// Redirect HTTP to HTTPS in production
if (
process.env.NODE_ENV === "production" &&
request.headers.get("x-forwarded-proto") !== "https"
) {
return NextResponse.redirect(
`https://${request.headers.get("host")}${request.nextUrl.pathname}`,
301
);
}
// Continue with auth handling
return authHandler(request);
}The SDK uses secure cookie defaults, but verify in production:
{
httpOnly: true, // ✓ Prevents XSS attacks
secure: true, // ✓ HTTPS only
sameSite: "lax", // ✓ CSRF protection
maxAge: 2592000, // ✓ 30 days
path: "/", // ✓ Available on all routes
}The SDK implements PKCE (Proof Key for Code Exchange) by default for all OAuth providers:
// PKCE is automatic - nothing to configure
const provider = google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: process.env.GOOGLE_REDIRECT_URI!,
// pkce: "S256" is the default
});The SDK automatically handles CSRF protection for OAuth flows:
// Automatic CSRF protection
// 1. User clicks "Sign in with Google"
const state = generateSecureToken(); // Random 32-byte token
csrfStore.set(state, true); // Store server-side
// 2. Redirect to Google with state
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?state=${state}...`;
// 3. Google redirects back with state
const callbackState = request.url.searchParams.get("state");
// 4. Validate state matches
if (!csrfStore.has(callbackState)) {
throw new Error("CSRF validation failed");
}
// 5. Clean up
csrfStore.delete(callbackState);For custom forms (email sign-in, etc.), implement CSRF tokens:
Protect custom authentication forms
// Generate CSRF token
import { generateCsrfToken, validateCsrfToken } from "@warpy-auth-sdk/core/utils/csrf";
// 1. Generate token when rendering form
const csrfToken = generateCsrfToken();
// 2. Include in form
<form method="POST" action="/api/auth/signin/email">
<input type="hidden" name="csrfToken" value={csrfToken} />
<input type="email" name="email" required />
<button type="submit">Sign In</button>
</form>
// 3. Validate on submission
export async function POST(request: NextRequest) {
const formData = await request.formData();
const csrfToken = formData.get("csrfToken") as string;
if (!validateCsrfToken(csrfToken)) {
return Response.json({ error: "Invalid CSRF token" }, { status: 403 });
}
// Proceed with authentication
}Implement rate limiting to prevent brute force attacks:
Limit authentication attempts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute
});
export async function POST(request: NextRequest) {
// Get client IP
const ip = request.headers.get("x-forwarded-for") || "unknown";
// Check rate limit
const { success } = await ratelimit.limit(ip);
if (!success) {
return Response.json(
{ error: "Too many requests. Please try again later." },
{ status: 429 }
);
}
// Proceed with authentication
const result = await authenticate(config, request);
// ...
}Limit failed login attempts per user
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
async function checkFailedAttempts(email: string): Promise<boolean> {
const key = `failed-login:${email}`;
const attempts = await redis.get(key) || 0;
if (attempts >= 5) {
// Check if lockout period has passed
const ttl = await redis.ttl(key);
if (ttl > 0) {
throw new Error(`Account locked. Try again in ${Math.ceil(ttl / 60)} minutes.`);
}
}
return true;
}
async function recordFailedAttempt(email: string): Promise<void> {
const key = `failed-login:${email}`;
await redis.incr(key);
await redis.expire(key, 900); // 15 minute lockout
}
async function clearFailedAttempts(email: string): Promise<void> {
const key = `failed-login:${email}`;
await redis.del(key);
}
// Usage
try {
await checkFailedAttempts(email);
const result = await authenticate(config, request);
if (result.session) {
await clearFailedAttempts(email);
} else {
await recordFailedAttempts(email);
}
} catch (error) {
await recordFailedAttempts(email);
throw error;
}Prevent injection attacks
import { z } from "zod";
const emailSchema = z.string().email().max(255);
export async function POST(request: NextRequest) {
const { email } = await request.json();
// Validate email format
const result = emailSchema.safeParse(email);
if (!result.success) {
return Response.json(
{ error: "Invalid email address" },
{ status: 400 }
);
}
// Sanitize email (lowercase, trim)
const sanitizedEmail = result.data.toLowerCase().trim();
// Proceed with authentication
const authResult = await authenticate(config, request, {
email: sanitizedEmail,
});
}Always use parameterized queries or ORM:
// ✗ NEVER do this (vulnerable to SQL injection)
const user = await db.execute(
`SELECT * FROM users WHERE email = '${email}'`
);
// ✓ Use parameterized queries
const user = await db.execute(
"SELECT * FROM users WHERE email = ?",
[email]
);
// ✓ Or use an ORM like Prisma
const user = await prisma.user.findUnique({
where: { email },
});Prevent XSS attacks
// Next.js config
export const config = {
headers: async () => [
{
source: "/:path*",
headers: [
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' https://accounts.google.com",
].join("; "),
},
],
},
],
};// ✗ Don't render raw HTML from user input
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// ✓ Escape user input automatically
<div>{userInput}</div>
// ✓ Or use a sanitization library
import DOMPurify from "dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />Use appropriate token expiration times:
// Standard user sessions: 30 days
maxAge: 60 * 60 * 24 * 30
// Admin/sensitive operations: 1 hour
maxAge: 60 * 60
// MCP agent tokens: 15 minutes
expiresIn: "15m"
// API tokens: 7 days
maxAge: 60 * 60 * 24 * 7Enable immediate session revocation with database adapter:
Sign out all devices when password changes
async function changePassword(userId: string, newPassword: string) {
// Update password
await db.user.update({
where: { id: userId },
data: { password: await hash(newPassword) },
});
// Revoke all existing sessions
await adapter.deleteUserSessions(userId);
// Force user to re-authenticate
}Detect suspicious session activity
// Store session metadata
interface SessionMetadata {
userAgent: string;
ip: string;
createdAt: Date;
lastActivityAt: Date;
}
// Check for changes
async function validateSessionSecurity(
session: Session,
request: NextRequest
): Promise<boolean> {
const currentIp = request.headers.get("x-forwarded-for");
const currentUserAgent = request.headers.get("user-agent");
const metadata = await getSessionMetadata(session.token);
// Flag if IP or user agent changed
if (
metadata.ip !== currentIp ||
metadata.userAgent !== currentUserAgent
) {
// Log suspicious activity
await logSecurityEvent({
type: "session_hijack_attempt",
sessionId: session.token,
oldIp: metadata.ip,
newIp: currentIp,
});
// Optionally revoke session
await adapter.deleteSession(session.token);
return false;
}
return true;
}Use CAPTCHA to prevent bot attacks:
Protect against automated attacks
import { createCaptchaProvider } from "@warpy-auth-sdk/core/captcha";
const config = {
secret: process.env.AUTH_SECRET!,
provider: email({ /* ... */ }),
captcha: {
provider: createCaptchaProvider({
type: "recaptcha-v3",
secretKey: process.env.RECAPTCHA_SECRET_KEY!,
}),
enabledMethods: ["email", "twofa"], // Only for email/2FA
scoreThreshold: 0.5, // Minimum score for reCAPTCHA v3
},
};
// Client-side
const captchaToken = await grecaptcha.execute(siteKey, { action: "login" });
await fetch("/api/auth/signin/email", {
method: "POST",
body: JSON.stringify({ email, captchaToken }),
});Track authentication events
interface SecurityEvent {
type: "login_success" | "login_failure" | "logout" | "session_revoked" | "suspicious_activity";
userId?: string;
email?: string;
ip: string;
userAgent: string;
timestamp: Date;
metadata?: Record<string, any>;
}
async function logSecurityEvent(event: SecurityEvent) {
await db.securityLog.create({
data: event,
});
// Alert on suspicious patterns
if (event.type === "login_failure") {
const recentFailures = await db.securityLog.count({
where: {
email: event.email,
type: "login_failure",
timestamp: { gt: new Date(Date.now() - 5 * 60 * 1000) },
},
});
if (recentFailures >= 5) {
await sendSecurityAlert({
type: "brute_force_attempt",
email: event.email,
failureCount: recentFailures,
});
}
}
}
// Usage
try {
const result = await authenticate(config, request);
await logSecurityEvent({
type: "login_success",
userId: result.session.user.id,
email: result.session.user.email,
ip: request.headers.get("x-forwarded-for") || "unknown",
userAgent: request.headers.get("user-agent") || "unknown",
timestamp: new Date(),
});
} catch (error) {
await logSecurityEvent({
type: "login_failure",
email: email,
ip: request.headers.get("x-forwarded-for") || "unknown",
userAgent: request.headers.get("user-agent") || "unknown",
timestamp: new Date(),
metadata: { error: error.message },
});
}Always validate redirect URIs in OAuth providers:
# Must match EXACTLY in OAuth provider settings
GOOGLE_REDIRECT_URI=https://yourdomain.com/api/auth/callback/google
# NOT:
# http://yourdomain.com/... (wrong protocol)
# https://yourdomain.com/callback/google (wrong path)
# https://www.yourdomain.com/... (wrong subdomain)The SDK automatically includes and validates the state parameter for CSRF protection.
Store OAuth tokens securely:
// ✓ Store access tokens in database (encrypted)
const encryptedToken = await encrypt(accessToken);
await db.account.update({
where: { id: account.id },
data: { accessToken: encryptedToken },
});
// ✗ Never expose access tokens to client
// ✗ Never log access tokens
// ✗ Never commit access tokens to version controlAUTH_SECRET is 32+ characters and cryptographically randomhttpOnly: true, secure: true, sameSite: "lax")AUTH_SECRET