Skip to main content

NextJS Auth Middleware: Configuration and Setup

Authentication configuration is the bridge between your auth code and the external services it depends on: databases, cache stores, secret managers, and third-party identity providers. In Next.js, configuration is managed via environment variables loaded from .env.local (development) and deployed to your hosting platform (production). Misconfiguration is the leading cause of authentication failures in production, so understanding how to set up environment variables, validate configurations, and securely manage secrets is essential for any real application.

Environment Variables and Secret Management

Environment variables in .env.local are loaded by Next.js at build time. Never commit .env.local to version control; instead, create a .env.example file showing which variables are required, and populate .env.local locally and via your hosting platform's secret manager.

Here is a complete .env.example for auth:

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/auth_db

# Session Store (Redis for caching sessions)
REDIS_URL=redis://localhost:6379

# Encryption (for signing/verifying JWTs or encrypting sensitive data)
SESSION_ENCRYPTION_KEY=your-64-char-hex-string-here

# Third-party Auth (if using OAuth)
GITHUB_CLIENT_ID=xxx
GITHUB_CLIENT_SECRET=xxx
GOOGLE_CLIENT_ID=xxx
GOOGLE_CLIENT_SECRET=xxx

# App Configuration
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-secret-key

# Email Service (for password reset)
SMTP_HOST=smtp.resend.com
SMTP_PORT=465
[email protected]
SMTP_PASSWORD=xxx

For development, create .env.local with test values:

DATABASE_URL=postgresql://postgres:password@localhost:5432/auth_db
REDIS_URL=redis://localhost:6379
SESSION_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
GITHUB_CLIENT_ID=dev-id
GITHUB_CLIENT_SECRET=dev-secret
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=dev-secret-do-not-use-in-production
SMTP_HOST=smtp.resend.com
SMTP_PORT=465
[email protected]
SMTP_PASSWORD=dev-password

In production (Vercel, Docker, etc.), use your platform's secret manager to populate these variables securely. Never hardcode secrets; always use environment variables.

Validating Configuration at Startup

Create a configuration validation function that runs at server startup and fails loudly if required variables are missing:

// lib/config.ts
export interface AppConfig {
databaseUrl: string;
redisUrl: string;
sessionEncryptionKey: string;
nextAuthUrl: string;
nextAuthSecret: string;
smtpHost: string;
smtpPort: number;
smtpUser: string;
smtpPassword: string;
}

export function validateConfig(): AppConfig {
const required = [
'DATABASE_URL',
'SESSION_ENCRYPTION_KEY',
'NEXTAUTH_URL',
'NEXTAUTH_SECRET',
];

const missing = required.filter(key => !process.env[key]);

if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(', ')}. Check .env.local and .env.production.`
);
}

return {
databaseUrl: process.env.DATABASE_URL!,
redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
sessionEncryptionKey: process.env.SESSION_ENCRYPTION_KEY!,
nextAuthUrl: process.env.NEXTAUTH_URL!,
nextAuthSecret: process.env.NEXTAUTH_SECRET!,
smtpHost: process.env.SMTP_HOST || 'smtp.resend.com',
smtpPort: parseInt(process.env.SMTP_PORT || '465'),
smtpUser: process.env.SMTP_USER || '',
smtpPassword: process.env.SMTP_PASSWORD || '',
};
}

// Export the validated config so you can use it throughout your app
export const config = validateConfig();

Call validateConfig() in your root layout or API initialization to ensure all required variables are present before the server starts. This catches configuration errors immediately instead of at runtime when a route tries to connect to the database.

Setting Up Database Connection Pool

For production, use a connection pool to reuse database connections instead of creating a new one per request:

// lib/db.ts
import { Pool, PoolClient } from 'pg';
import { config } from './config';

// Reuse the connection pool across requests
let pool: Pool | null = null;

export function getPool(): Pool {
if (!pool) {
pool = new Pool({
connectionString: config.databaseUrl,
max: 20, // Maximum number of connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});

pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
});
}

return pool;
}

export async function query(text: string, params?: unknown[]): Promise<{ rows: unknown[]; rowCount: number }> {
const pool = getPool();
return pool.query(text, params);
}

export async function withTransaction<T>(
callback: (client: PoolClient) => Promise<T>
): Promise<T> {
const pool = getPool();
const client = await pool.connect();

try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}

This setup ensures that:

  • Connections are pooled and reused, reducing overhead.
  • Errors on idle connections are logged, not silently ignored.
  • Transactions are properly rolled back on error.
  • The pool is initialized once and shared across all requests.

Configuring Redis for Session Caching

Redis is much faster than a database for session lookups. Configure Redis as a cache layer in front of your database:

// lib/redis.ts
import { createClient, RedisClientType } from 'redis';
import { config } from './config';

let redisClient: RedisClientType | null = null;

export async function getRedis(): Promise<RedisClientType> {
if (!redisClient) {
redisClient = createClient({ url: config.redisUrl });

redisClient.on('error', (err) => {
console.error('Redis client error', err);
});

await redisClient.connect();
}

return redisClient;
}

export async function getSessionFromCache(sessionId: string) {
const redis = await getRedis();
const sessionJson = await redis.get(`session:${sessionId}`);
return sessionJson ? JSON.parse(sessionJson) : null;
}

export async function setSessionInCache(sessionId: string, session: unknown, ttl: number) {
const redis = await getRedis();
await redis.setEx(`session:${sessionId}`, ttl, JSON.stringify(session));
}

export async function deleteSessionFromCache(sessionId: string) {
const redis = await getRedis();
await redis.del(`session:${sessionId}`);
}

With this setup, session lookups hit Redis first (microseconds) and fall back to the database if the cache misses. The ttl (time-to-live) parameter ensures cached sessions expire automatically.

Conditional Configuration for Development and Production

Different environments have different requirements. Develop this pattern to safely switch behavior:

// lib/auth-provider.ts
import { config } from './config';

export const isDevelopment = process.env.NODE_ENV === 'development';
export const isProduction = process.env.NODE_ENV === 'production';

// In development, log auth events for debugging
export function logAuth(message: string, data?: unknown) {
if (isDevelopment) {
console.log(`[AUTH] ${message}`, data || '');
}
}

// In production, use only secure cookies and HTTPS
export const cookieOptions = {
httpOnly: true,
secure: isProduction,
sameSite: 'lax' as const,
path: '/',
};

// In development, allow longer session timeouts for testing
export const SESSION_MAX_AGE = isDevelopment ? 30 * 24 * 60 * 60 : 7 * 24 * 60 * 60;

// In production, require specific auth providers
export const requiredAuthProviders = isProduction ? ['email', 'github'] : ['email'];

This approach makes your auth system flexible: developers can work locally with relaxed settings, while production enforces strict security.

Key Takeaways

  • Use .env.local for development secrets and .env.example as a template; never commit actual secrets.
  • Validate all required environment variables at server startup with a dedicated validation function.
  • Use connection pools for databases to reuse connections and reduce overhead per request.
  • Cache sessions in Redis for sub-millisecond lookup times with automatic expiry.
  • Differ behavior between development and production using process.env.NODE_ENV.
  • Use your platform's secret manager (Vercel, AWS Secrets Manager, etc.) for production secrets.

Frequently Asked Questions

How do I generate a strong SESSION_ENCRYPTION_KEY?

Use a cryptographically secure random generator: openssl rand -hex 32 generates a 64-character hex string. For Node.js: require('crypto').randomBytes(32).toString('hex'). Store this string in .env.local and keep it secret. Never hardcode it in your source code.

What if a secret is accidentally committed to git?

First, revoke the secret in your hosting platform immediately. Then, use git filter-branch or git filter-repo to remove the secret from all commits in the repository. Finally, push the cleaned history to your remote. Never ignore the commit; malicious actors can find secrets in public git histories.

Can I use Vercel Environment Variables instead of .env.local?

Yes, but only for production. Vercel pulls variables from its dashboard into your deployed app. For local development, use .env.local. CI/CD pipelines can also reference Vercel secrets via the vercel env pull command to download variables locally.

Why should I cache sessions in Redis instead of the database?

Databases are optimized for writes and complex queries. Session lookups are simple, frequent reads—Redis is much faster (microseconds vs milliseconds). Redis also naturally expires keys, so you do not need a background job to clean up old sessions.

Further Reading