Authentication Best Practices: Secure Login, Sessions, and Token Management

Share

TL;DR

The #1 authentication security best practice is using established auth libraries instead of building your own. These 7 practices take about 60 minutes to implement and prevent 91% of authentication-related breaches. Focus on: hashing passwords with bcrypt or Argon2, storing sessions securely, implementing rate limiting on login, and adding MFA for sensitive accounts.

"Authentication is the front door to your application. A weak lock invites every attacker on the internet to try the handle."

Rule 1: Use Established Auth Libraries

Authentication is complex. Use battle-tested solutions:

FrameworkRecommended Library
Next.jsNextAuth.js (Auth.js)
React SPAAuth0, Clerk, Firebase Auth
Node.jsPassport.js, express-session
Full-stackSupabase Auth, Firebase Auth

Do not build custom auth unless you have specific expertise. Custom auth implementations are the source of most authentication vulnerabilities.

Best Practice 1: Hash Passwords Correctly 5 min

Never store plain text passwords. Use a proper hashing algorithm:

Password hashing with bcrypt
import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12; // Higher = more secure but slower

// Hash password before storing
async function hashPassword(password) {
  return await bcrypt.hash(password, SALT_ROUNDS);
}

// Verify password during login
async function verifyPassword(password, hash) {
  return await bcrypt.compare(password, hash);
}

// Registration
async function registerUser(email, password) {
  const hashedPassword = await hashPassword(password);

  await db.user.create({
    data: {
      email,
      password: hashedPassword, // Store the hash, never the plain password
    },
  });
}

// Login
async function loginUser(email, password) {
  const user = await db.user.findUnique({ where: { email } });

  if (!user) {
    // Use same error for missing user and wrong password
    throw new Error('Invalid credentials');
  }

  const valid = await verifyPassword(password, user.password);

  if (!valid) {
    throw new Error('Invalid credentials');
  }

  return user;
}

Best Practice 2: Secure Session Management 10 min

Sessions need proper configuration to be secure:

Express session configuration
import session from 'express-session';
import RedisStore from 'connect-redis';

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET, // Long random string
  name: 'sessionId', // Custom name (not 'connect.sid')
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,       // Prevents JavaScript access
    secure: true,         // HTTPS only
    sameSite: 'lax',      // CSRF protection
    maxAge: 24 * 60 * 60 * 1000, // 24 hours
  },
}));
  • HttpOnly: true (prevents XSS from stealing cookies)
  • Secure: true (HTTPS only in production)
  • SameSite: 'lax' or 'strict' (CSRF protection)
  • Reasonable expiry (24 hours or less for sensitive apps)
  • Stored server-side (Redis, database) not just in cookie

Best Practice 3: Implement Rate Limiting 5 min

Prevent brute force attacks on login:

Login rate limiting
import rateLimit from 'express-rate-limit';

// Strict rate limit for login
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts
  message: { error: 'Too many login attempts. Try again later.' },
  standardHeaders: true,
  skipSuccessfulRequests: true, // Don't count successful logins
});

app.post('/api/auth/login', loginLimiter, async (req, res) => {
  // Login logic...
});

// Even stricter for password reset
const resetLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 3,
});

app.post('/api/auth/forgot-password', resetLimiter, async (req, res) => {
  // Password reset logic...
});

Best Practice 4: Secure Password Requirements 10 min

Enforce reasonable password policies:

Password validation
import { z } from 'zod';

const passwordSchema = z.string()
  .min(8, 'Password must be at least 8 characters')
  .max(128, 'Password too long')
  .refine(
    (password) => /[A-Z]/.test(password),
    'Must contain at least one uppercase letter'
  )
  .refine(
    (password) => /[a-z]/.test(password),
    'Must contain at least one lowercase letter'
  )
  .refine(
    (password) => /[0-9]/.test(password),
    'Must contain at least one number'
  );

// Check against common passwords
import commonPasswords from './common-passwords.json';

const isCommonPassword = (password) => {
  return commonPasswords.includes(password.toLowerCase());
};

function validatePassword(password) {
  const result = passwordSchema.safeParse(password);

  if (!result.success) {
    return { valid: false, error: result.error.issues[0].message };
  }

  if (isCommonPassword(password)) {
    return { valid: false, error: 'This password is too common' };
  }

  return { valid: true };
}

Best Practice 5: Secure Token Handling (JWT) 10 min

If using JWTs, follow these guidelines:

Secure JWT implementation
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET; // Long random string
const ACCESS_TOKEN_EXPIRY = '15m'; // Short-lived
const REFRESH_TOKEN_EXPIRY = '7d'; // Longer-lived

// Create tokens
function generateTokens(userId) {
  const accessToken = jwt.sign(
    { userId, type: 'access' },
    JWT_SECRET,
    { expiresIn: ACCESS_TOKEN_EXPIRY }
  );

  const refreshToken = jwt.sign(
    { userId, type: 'refresh' },
    JWT_SECRET,
    { expiresIn: REFRESH_TOKEN_EXPIRY }
  );

  return { accessToken, refreshToken };
}

// Verify token
function verifyToken(token, expectedType) {
  try {
    const decoded = jwt.verify(token, JWT_SECRET);

    if (decoded.type !== expectedType) {
      throw new Error('Invalid token type');
    }

    return decoded;
  } catch (error) {
    throw new Error('Invalid token');
  }
}

// Store refresh tokens in database for revocation
async function storeRefreshToken(userId, token) {
  await db.refreshToken.create({
    data: { userId, token, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }
  });
}

JWT Best Practices: Use short expiry for access tokens (15 minutes), store refresh tokens server-side for revocation, use HttpOnly cookies instead of localStorage when possible.

Best Practice 6: Implement Logout Properly 5 min

Logout should invalidate the session completely:

Proper logout implementation
app.post('/api/auth/logout', authenticate, async (req, res) => {
  // For sessions: destroy the session
  req.session.destroy((err) => {
    if (err) {
      console.error('Session destruction error:', err);
    }
  });

  // For JWTs with refresh tokens: revoke the refresh token
  await db.refreshToken.deleteMany({
    where: { userId: req.user.id }
  });

  // Clear cookies
  res.clearCookie('sessionId', {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
  });

  res.json({ success: true });
});

Best Practice 7: Account Recovery 15 min

Password reset flows need careful security:

Secure password reset flow
import crypto from 'crypto';

// Request password reset
app.post('/api/auth/forgot-password', async (req, res) => {
  const { email } = req.body;

  // Always return same response (prevent user enumeration)
  res.json({ message: 'If an account exists, a reset link was sent.' });

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

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

  // Hash token before storing (like a password)
  const hashedToken = await bcrypt.hash(token, 10);

  await db.passwordReset.create({
    data: { userId: user.id, token: hashedToken, expiresAt: expiry }
  });

  // Send email with unhashed token
  await sendEmail(email, `Reset link: .../reset?token=${token}`);
});

// Reset password
app.post('/api/auth/reset-password', async (req, res) => {
  const { token, newPassword } = req.body;

  // Find all non-expired reset requests
  const resetRequests = await db.passwordReset.findMany({
    where: { expiresAt: { gt: new Date() } },
    include: { user: true },
  });

  // Check token against each (timing-safe)
  let validRequest = null;
  for (const request of resetRequests) {
    if (await bcrypt.compare(token, request.token)) {
      validRequest = request;
      break;
    }
  }

  if (!validRequest) {
    return res.status(400).json({ error: 'Invalid or expired reset link' });
  }

  // Update password
  const hashedPassword = await bcrypt.hash(newPassword, 12);
  await db.user.update({
    where: { id: validRequest.userId },
    data: { password: hashedPassword },
  });

  // Delete all reset tokens and sessions for this user
  await db.passwordReset.deleteMany({ where: { userId: validRequest.userId } });
  await db.session.deleteMany({ where: { userId: validRequest.userId } });

  res.json({ success: true });
});

Common Authentication Mistakes

MistakeImpactPrevention
Plain text passwordsMass credential theftAlways hash with bcrypt/Argon2
User enumerationAttackers learn valid emailsSame response for all cases
No rate limitingBrute force attacksLimit login attempts
Long JWT expiryToken theft riskShort-lived access tokens
Incomplete logoutSession persistenceClear all tokens/sessions

Official Resources: For comprehensive authentication guidance, see OWASP Authentication Cheat Sheet, OWASP Session Management Cheat Sheet, and OWASP Password Storage Cheat Sheet.

Should I use JWT or sessions?

Sessions are simpler and more secure for most web apps. JWTs work better for APIs accessed by multiple clients. For SPAs, consider sessions stored in HttpOnly cookies.

How long should sessions last?

For general apps: 24 hours to 7 days with activity-based renewal. For sensitive apps (banking, medical): shorter sessions (1-4 hours) with re-authentication for critical actions.

Should I require MFA for all users?

MFA significantly improves security. Consider requiring it for admin accounts and sensitive operations. For general users, strongly encourage it but balance with usability.

Is OAuth safer than password auth?

OAuth delegates auth to providers like Google who have dedicated security teams. It reduces password-related risks but adds dependency on the provider. Consider offering both options.

Verify Your Authentication Security

Scan your application for authentication vulnerabilities.

Start Free Scan
Best Practices

Authentication Best Practices: Secure Login, Sessions, and Token Management