tampertantrum-labs

auth-patterns

0
0
# Install this skill:
npx skills add tampertantrum-labs/security-skills --skill "auth-patterns"

Install specific skill from multi-skill repository

# Description

Authentication patterns done right. JWT, sessions, refresh tokens, OAuth, and password handling with security best practices.

# SKILL.md


name: auth-patterns
description: Authentication patterns done right. JWT, sessions, refresh tokens, OAuth, and password handling with security best practices.


Auth Patterns

Implement authentication that's actually secure. Covers JWT, sessions, refresh token rotation, OAuth, password hashing, and MFA.

When to Use This Skill

  • Implementing user authentication
  • Building login/signup flows
  • Integrating OAuth providers
  • Managing sessions and tokens
  • Implementing password reset flows
  • Adding MFA/2FA

When NOT to Use This Skill

  • Public-only applications
  • Using fully managed auth (Auth0, Clerk) - follow their docs

JWT Authentication

Token Generation

// lib/jwt.ts
import { SignJWT, jwtVerify } from 'jose';

const ACCESS_TOKEN_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
const REFRESH_TOKEN_SECRET = new TextEncoder().encode(process.env.REFRESH_SECRET!);

interface TokenPayload {
  userId: string;
  role: 'user' | 'admin';
}

// Short-lived access token (15 minutes)
export async function generateAccessToken(payload: TokenPayload): Promise<string> {
  return new SignJWT({ ...payload })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m')
    .setJti(crypto.randomUUID()) // Unique token ID
    .sign(ACCESS_TOKEN_SECRET);
}

// Longer-lived refresh token (7 days)
export async function generateRefreshToken(userId: string): Promise<string> {
  return new SignJWT({ userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .setJti(crypto.randomUUID())
    .sign(REFRESH_TOKEN_SECRET);
}

export async function verifyAccessToken(token: string): Promise<TokenPayload | null> {
  try {
    const { payload } = await jwtVerify(token, ACCESS_TOKEN_SECRET);
    return payload as TokenPayload;
  } catch {
    return null;
  }
}

export async function verifyRefreshToken(token: string): Promise<{ userId: string } | null> {
  try {
    const { payload } = await jwtVerify(token, REFRESH_TOKEN_SECRET);
    return payload as { userId: string };
  } catch {
    return null;
  }
}

Token Storage (httpOnly Cookies)

// ❌ BAD: localStorage (vulnerable to XSS)
localStorage.setItem('accessToken', token);

// βœ… GOOD: httpOnly cookies (not accessible via JS)
// app/api/auth/login/route.ts
import { cookies } from 'next/headers';

export async function POST(request: Request) {
  // ... validate credentials

  const accessToken = await generateAccessToken({ userId: user.id, role: user.role });
  const refreshToken = await generateRefreshToken(user.id);

  // Store refresh token hash in database (for revocation)
  await db.refreshToken.create({
    data: {
      userId: user.id,
      tokenHash: await hashToken(refreshToken),
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  });

  // Set cookies
  cookies().set('accessToken', accessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 15 * 60, // 15 minutes
    path: '/',
  });

  cookies().set('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 7 * 24 * 60 * 60, // 7 days
    path: '/api/auth', // Only sent to auth endpoints
  });

  return Response.json({ success: true, user: { id: user.id, email: user.email } });
}

Refresh Token Rotation

// app/api/auth/refresh/route.ts
import { cookies } from 'next/headers';

export async function POST() {
  const refreshToken = cookies().get('refreshToken')?.value;

  if (!refreshToken) {
    return Response.json({ error: 'No refresh token' }, { status: 401 });
  }

  // Verify token
  const payload = await verifyRefreshToken(refreshToken);
  if (!payload) {
    // Clear cookies on invalid token
    cookies().delete('accessToken');
    cookies().delete('refreshToken');
    return Response.json({ error: 'Invalid refresh token' }, { status: 401 });
  }

  // Check if token exists in database (not revoked)
  const storedToken = await db.refreshToken.findFirst({
    where: {
      userId: payload.userId,
      tokenHash: await hashToken(refreshToken),
      expiresAt: { gt: new Date() },
      revokedAt: null,
    },
  });

  if (!storedToken) {
    // Token reuse detected - revoke all tokens for this user
    await db.refreshToken.updateMany({
      where: { userId: payload.userId },
      data: { revokedAt: new Date() },
    });

    cookies().delete('accessToken');
    cookies().delete('refreshToken');

    // Log security event
    console.warn('Refresh token reuse detected:', { userId: payload.userId });

    return Response.json({ error: 'Token reuse detected' }, { status: 401 });
  }

  // Rotate: Revoke old token, issue new pair
  await db.refreshToken.update({
    where: { id: storedToken.id },
    data: { revokedAt: new Date() },
  });

  const user = await db.user.findUnique({ where: { id: payload.userId } });
  if (!user) {
    return Response.json({ error: 'User not found' }, { status: 401 });
  }

  // Generate new tokens
  const newAccessToken = await generateAccessToken({ userId: user.id, role: user.role });
  const newRefreshToken = await generateRefreshToken(user.id);

  // Store new refresh token
  await db.refreshToken.create({
    data: {
      userId: user.id,
      tokenHash: await hashToken(newRefreshToken),
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  });

  // Set new cookies
  cookies().set('accessToken', newAccessToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 15 * 60,
    path: '/',
  });

  cookies().set('refreshToken', newRefreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 7 * 24 * 60 * 60,
    path: '/api/auth',
  });

  return Response.json({ success: true });
}

Session-Based Authentication

Secure Session Setup

// lib/session.ts
import { cookies } from 'next/headers';
import { db } from './db';
import crypto from 'crypto';

const SESSION_COOKIE = 'session_id';
const SESSION_DURATION = 24 * 60 * 60 * 1000; // 24 hours

export async function createSession(userId: string): Promise<string> {
  // Generate cryptographically secure session ID
  const sessionId = crypto.randomBytes(32).toString('hex');

  // Store in database
  await db.session.create({
    data: {
      id: sessionId,
      userId,
      expiresAt: new Date(Date.now() + SESSION_DURATION),
      createdAt: new Date(),
      userAgent: '', // Can store for anomaly detection
      ipAddress: '', // Can store for anomaly detection
    },
  });

  // Set cookie
  cookies().set(SESSION_COOKIE, sessionId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: SESSION_DURATION / 1000,
    path: '/',
  });

  return sessionId;
}

export async function getSession(): Promise<{ userId: string; role: string } | null> {
  const sessionId = cookies().get(SESSION_COOKIE)?.value;
  if (!sessionId) return null;

  const session = await db.session.findUnique({
    where: { id: sessionId },
    include: { user: { select: { id: true, role: true } } },
  });

  if (!session || session.expiresAt < new Date()) {
    cookies().delete(SESSION_COOKIE);
    return null;
  }

  // Extend session on activity (sliding expiration)
  await db.session.update({
    where: { id: sessionId },
    data: { expiresAt: new Date(Date.now() + SESSION_DURATION) },
  });

  return { userId: session.user.id, role: session.user.role };
}

export async function destroySession(): Promise<void> {
  const sessionId = cookies().get(SESSION_COOKIE)?.value;

  if (sessionId) {
    await db.session.delete({ where: { id: sessionId } }).catch(() => {});
  }

  cookies().delete(SESSION_COOKIE);
}

// Destroy all sessions for a user (logout everywhere)
export async function destroyAllSessions(userId: string): Promise<void> {
  await db.session.deleteMany({ where: { userId } });
}

Password Handling

Hashing with Argon2

// lib/password.ts
import { hash, verify } from 'argon2';

// Argon2id is recommended for password hashing
const ARGON_OPTIONS = {
  type: 2, // argon2id
  memoryCost: 65536, // 64MB
  timeCost: 3,
  parallelism: 4,
};

export async function hashPassword(password: string): Promise<string> {
  return hash(password, ARGON_OPTIONS);
}

export async function verifyPassword(password: string, hash: string): Promise<boolean> {
  try {
    return await verify(hash, password);
  } catch {
    return false;
  }
}

Hashing with bcrypt (Alternative)

// lib/password.ts
import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12; // Adjust based on your server's performance

export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

export async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

Password Requirements

import { z } from 'zod';

export const passwordSchema = z
  .string()
  .min(12, 'Password must be at least 12 characters')
  .max(128, 'Password too long')
  .regex(/[A-Z]/, 'Must contain at least one uppercase letter')
  .regex(/[a-z]/, 'Must contain at least one lowercase letter')
  .regex(/[0-9]/, 'Must contain at least one number')
  .regex(/[^A-Za-z0-9]/, 'Must contain at least one special character')
  .refine(
    (password) => !commonPasswords.includes(password.toLowerCase()),
    'This password is too common'
  );

// Check against breach databases
import { pwnedPassword } from 'hibp';

export async function isPasswordBreached(password: string): Promise<boolean> {
  const count = await pwnedPassword(password);
  return count > 0;
}

OAuth Integration

OAuth 2.0 + PKCE Flow

// lib/oauth.ts
import crypto from 'crypto';

// Generate PKCE code verifier and challenge
export function generatePKCE(): { verifier: string; challenge: string } {
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');

  return { verifier, challenge };
}

// Generate state parameter (CSRF protection)
export function generateState(): string {
  return crypto.randomBytes(16).toString('hex');
}

// Build authorization URL
export function buildAuthUrl(provider: 'google' | 'github'): {
  url: string;
  state: string;
  codeVerifier: string;
} {
  const { verifier, challenge } = generatePKCE();
  const state = generateState();

  const configs = {
    google: {
      authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
      clientId: process.env.GOOGLE_CLIENT_ID!,
      scopes: ['openid', 'email', 'profile'],
    },
    github: {
      authUrl: 'https://github.com/login/oauth/authorize',
      clientId: process.env.GITHUB_CLIENT_ID!,
      scopes: ['read:user', 'user:email'],
    },
  };

  const config = configs[provider];
  const params = new URLSearchParams({
    client_id: config.clientId,
    redirect_uri: `${process.env.APP_URL}/api/auth/callback/${provider}`,
    response_type: 'code',
    scope: config.scopes.join(' '),
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });

  return {
    url: `${config.authUrl}?${params}`,
    state,
    codeVerifier: verifier,
  };
}

OAuth Callback Handler

// app/api/auth/callback/[provider]/route.ts
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { createSession } from '@/lib/session';

export async function GET(
  request: Request,
  { params }: { params: { provider: string } }
) {
  const url = new URL(request.url);
  const code = url.searchParams.get('code');
  const state = url.searchParams.get('state');
  const error = url.searchParams.get('error');

  // Check for OAuth error
  if (error) {
    return Response.redirect(`${process.env.APP_URL}/login?error=oauth_denied`);
  }

  // Verify state (CSRF protection)
  const storedState = cookies().get('oauth_state')?.value;
  const codeVerifier = cookies().get('oauth_verifier')?.value;

  if (!state || !storedState || state !== storedState) {
    return Response.redirect(`${process.env.APP_URL}/login?error=invalid_state`);
  }

  // Clear OAuth cookies
  cookies().delete('oauth_state');
  cookies().delete('oauth_verifier');

  if (!code || !codeVerifier) {
    return Response.redirect(`${process.env.APP_URL}/login?error=missing_code`);
  }

  try {
    // Exchange code for tokens
    const tokens = await exchangeCodeForTokens(params.provider, code, codeVerifier);

    // Get user info from provider
    const providerUser = await getProviderUser(params.provider, tokens.access_token);

    // Find or create user
    let user = await db.user.findFirst({
      where: {
        accounts: {
          some: {
            provider: params.provider,
            providerAccountId: providerUser.id,
          },
        },
      },
    });

    if (!user) {
      // Create new user
      user = await db.user.create({
        data: {
          email: providerUser.email,
          name: providerUser.name,
          emailVerified: new Date(), // OAuth emails are verified
          accounts: {
            create: {
              provider: params.provider,
              providerAccountId: providerUser.id,
              accessToken: tokens.access_token,
              refreshToken: tokens.refresh_token,
            },
          },
        },
      });
    }

    // Create session
    await createSession(user.id);

    return Response.redirect(`${process.env.APP_URL}/dashboard`);
  } catch (error) {
    console.error('OAuth error:', error);
    return Response.redirect(`${process.env.APP_URL}/login?error=oauth_failed`);
  }
}

Multi-Factor Authentication (MFA)

TOTP Setup

// lib/mfa.ts
import { authenticator } from 'otplib';
import QRCode from 'qrcode';

export async function generateMfaSecret(email: string): Promise<{
  secret: string;
  qrCode: string;
  backupCodes: string[];
}> {
  // Generate secret
  const secret = authenticator.generateSecret();

  // Generate QR code URL
  const otpauth = authenticator.keyuri(email, 'YourApp', secret);
  const qrCode = await QRCode.toDataURL(otpauth);

  // Generate backup codes
  const backupCodes = Array.from({ length: 10 }, () =>
    crypto.randomBytes(4).toString('hex').toUpperCase()
  );

  return { secret, qrCode, backupCodes };
}

export function verifyTotp(token: string, secret: string): boolean {
  return authenticator.verify({ token, secret });
}

export async function verifyBackupCode(
  userId: string,
  code: string
): Promise<boolean> {
  const hashedCode = await hashToken(code);

  const backupCode = await db.backupCode.findFirst({
    where: {
      userId,
      codeHash: hashedCode,
      usedAt: null,
    },
  });

  if (!backupCode) return false;

  // Mark as used
  await db.backupCode.update({
    where: { id: backupCode.id },
    data: { usedAt: new Date() },
  });

  return true;
}

MFA-Protected Login Flow

// app/api/auth/login/route.ts
export async function POST(request: Request) {
  const { email, password, mfaToken } = await request.json();

  // 1. Verify credentials
  const user = await db.user.findUnique({ where: { email } });
  if (!user || !await verifyPassword(password, user.passwordHash)) {
    // Don't reveal if user exists
    return Response.json({ error: 'Invalid credentials' }, { status: 401 });
  }

  // 2. Check if MFA is enabled
  if (user.mfaEnabled) {
    if (!mfaToken) {
      // Return indication that MFA is required
      return Response.json({ 
        requiresMfa: true,
        // Optionally return a temp token to continue the flow
      }, { status: 200 });
    }

    // Verify MFA token
    const isValidMfa = verifyTotp(mfaToken, user.mfaSecret!) ||
                       await verifyBackupCode(user.id, mfaToken);

    if (!isValidMfa) {
      return Response.json({ error: 'Invalid MFA token' }, { status: 401 });
    }
  }

  // 3. Create session
  await createSession(user.id);

  return Response.json({ success: true });
}

Password Reset Flow

// app/api/auth/forgot-password/route.ts
import { randomBytes } from 'crypto';

export async function POST(request: Request) {
  const { email } = await request.json();

  // Always return success to prevent email enumeration
  const successResponse = Response.json({ 
    message: 'If that email exists, a reset link has been sent.' 
  });

  const user = await db.user.findUnique({ where: { email } });
  if (!user) return successResponse;

  // Generate secure token
  const token = randomBytes(32).toString('hex');
  const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour

  // Store hashed token (don't store plain token)
  await db.passwordReset.create({
    data: {
      userId: user.id,
      tokenHash: await hashToken(token),
      expiresAt,
    },
  });

  // Send email with token
  await sendEmail({
    to: email,
    subject: 'Password Reset',
    html: `
      <p>Click the link below to reset your password:</p>
      <a href="${process.env.APP_URL}/reset-password?token=${token}">
        Reset Password
      </a>
      <p>This link expires in 1 hour.</p>
    `,
  });

  return successResponse;
}

// app/api/auth/reset-password/route.ts
export async function POST(request: Request) {
  const { token, newPassword } = await request.json();

  // Validate password
  const passwordResult = passwordSchema.safeParse(newPassword);
  if (!passwordResult.success) {
    return Response.json({ error: 'Invalid password' }, { status: 400 });
  }

  // Find valid reset token
  const resetRecord = await db.passwordReset.findFirst({
    where: {
      tokenHash: await hashToken(token),
      expiresAt: { gt: new Date() },
      usedAt: null,
    },
    include: { user: true },
  });

  if (!resetRecord) {
    return Response.json({ error: 'Invalid or expired token' }, { status: 400 });
  }

  // Update password
  const hashedPassword = await hashPassword(newPassword);

  await db.$transaction([
    // Update password
    db.user.update({
      where: { id: resetRecord.userId },
      data: { passwordHash: hashedPassword },
    }),
    // Mark token as used
    db.passwordReset.update({
      where: { id: resetRecord.id },
      data: { usedAt: new Date() },
    }),
    // Invalidate all sessions (logout everywhere)
    db.session.deleteMany({
      where: { userId: resetRecord.userId },
    }),
  ]);

  return Response.json({ success: true });
}

Rate Limiting Auth Endpoints

// middleware.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const authRatelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '15 m'), // 5 attempts per 15 min
});

const passwordResetRatelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(3, '1 h'), // 3 requests per hour
});

export async function middleware(request: NextRequest) {
  const ip = request.ip ?? 'unknown';

  if (request.nextUrl.pathname === '/api/auth/login') {
    const { success } = await authRatelimit.limit(ip);
    if (!success) {
      return Response.json(
        { error: 'Too many login attempts. Try again later.' },
        { status: 429 }
      );
    }
  }

  if (request.nextUrl.pathname === '/api/auth/forgot-password') {
    const { success } = await passwordResetRatelimit.limit(ip);
    if (!success) {
      return Response.json(
        { error: 'Too many requests. Try again later.' },
        { status: 429 }
      );
    }
  }
}

Anti-Patterns

Anti-Pattern Risk Solution
Storing JWT in localStorage XSS can steal tokens httpOnly cookies
Long-lived access tokens Extended exposure if stolen Short-lived (15 min) + refresh
No refresh token rotation Token reuse attacks Rotate on every refresh
MD5/SHA1 for passwords Rainbow table attacks bcrypt/argon2
Same response for all auth errors User enumeration Generic "Invalid credentials"
No MFA option Account takeover Offer TOTP MFA
Password reset tokens in URL forever Token theft Short expiry, one-time use
No rate limiting on auth Brute force Rate limit by IP

References


This skill is maintained by TamperTantrum Labs β€” making application security accessible, human, and empowering.

# Supported AI Coding Agents

This skill is compatible with the SKILL.md standard and works with all major AI coding agents:

Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.