Authentication in 2025 - JWT, Sessions, OAuth, and What Actually Works

Stop Rolling Your Own Auth (And How to Do It Right If You Must)

Authentication is hard. Really hard. You can build it in a weekend and spend 6 months fixing security holes. Let’s talk about what actually works in 2025.

The Security Hole I Shipped to Production

I built a simple authentication system. Username, password, done. Shipped it Friday afternoon.

Monday morning: “All user accounts have been compromised.”

What went wrong?

  1. Passwords stored in plain text
  2. No rate limiting (brute force worked)
  3. Predictable session tokens (sequential IDs)
  4. No HTTPS enforcement
  5. SQL injection in login form

Five critical security flaws in one weekend of coding.

That’s when I learned: Never roll your own auth unless you absolutely have to.

The Authentication Decision Tree

90% of apps should use an existing solution. Don’t build what’s already been battle-tested.

For most apps: Use Supabase or Clerk For OAuth-only: Use NextAuth.js For custom requirements: Build carefully with proper security

Supabase Auth (My Current Favorite)

What it includes:

  • Email/password, magic links, OAuth providers
  • Phone authentication
  • Row Level Security

Example:

import { createClient } from '@supabase/supabase-js';

const supabase = createClient('your-project-url', 'your-anon-key');

// Sign up
const { data, error } = await supabase.auth.signUp({
  email, password
});

// Sign in
const { data, error } = await supabase.auth.signInWithPassword({
  email, password
});

// OAuth (Google)
await supabase.auth.signInWithOAuth({ provider: 'google' });

Pros: Free tier, open source, fast to implement Cons: Vendor lock-in, less control over auth flow

Clerk (Best DX)

Beautiful UI out of the box with pre-built components. Best developer experience but expensive after free tier.

Auth0

Enterprise-grade, feature-rich, expensive. Use when you need compliance (SOC 2, HIPAA) and have budget.

Option 2: NextAuth.js / Auth.js

Best for: OAuth (Google, GitHub) without paying for auth service

// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import GitHubProvider from 'next-auth/providers/github';

export const authOptions = {
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET
    }),
    GitHubProvider({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET
    })
  ]
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

Pros: Free, supports many OAuth providers, flexible Cons: You manage database, handle security updates

Option 3: Build It Yourself (Proceed with Caution)

Password Hashing (NEVER Store Plain Text)

import bcrypt from 'bcrypt';

async function hashPassword(password: string): Promise<string> {
  const saltRounds = 10;
  return await bcrypt.hash(password, saltRounds);
}

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

Never use: Plain text, MD5, SHA1 Always use: bcrypt, scrypt, Argon2

JWT (JSON Web Tokens)

Stateless authentication. Server generates JWT, client stores it, sends with each request.

import jwt from 'jsonwebtoken';

// Generate token
function generateToken(user: { id: string; email: string }): string {
  return jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );
}

// Verify token
function verifyToken(token: string) {
  return jwt.verify(token, process.env.JWT_SECRET);
}

JWT Pros: Stateless, works across services JWT Cons: Can’t be revoked easily, XSS vulnerability if stored in localStorage

Session-Based Auth (Alternative to JWT)

Server creates session, stores session ID in cookie. More secure with httpOnly cookies.

Use JWT when: Building API for mobile, microservices, need stateless Use Sessions when: Traditional web app, need instant revocation, security > scalability

Security Best Practices

1. Always Use HTTPS

Redirect HTTP to HTTPS in production.

2. Rate Limiting

import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts
  message: 'Too many login attempts'
});

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

3. Password Requirements

function validatePassword(password: string): boolean {
  const minLength = 8;
  const hasUpperCase = /[A-Z]/.test(password);
  const hasLowerCase = /[a-z]/.test(password);
  const hasNumbers = /\d/.test(password);

  return password.length >= minLength && hasUpperCase && hasLowerCase && hasNumbers;
}

4. Two-Factor Authentication

Use libraries like speakeasy for TOTP-based 2FA.

Authentication Checklist

  • Passwords hashed with bcrypt/scrypt/Argon2
  • HTTPS enforced
  • Rate limiting on login/signup
  • Password requirements enforced
  • Account lockout after failed attempts
  • Email verification
  • Password reset flow
  • Session expiration
  • Secure cookie settings (httpOnly, secure, sameSite)
  • CSRF protection (for sessions)
  • XSS protection
  • SQL injection prevention

My Recommendations

For most apps: Use Supabase or Clerk

For OAuth-only: Use NextAuth.js

For custom requirements: Build it yourself, but:

  • Use bcrypt for passwords
  • Use sessions over JWT (unless you need stateless)
  • Add rate limiting
  • Enforce HTTPS
  • Get a security audit

Authentication seems simple until you ship to production. Then you realize it’s one of the hardest parts of building apps.

Use existing solutions when possible. If you must build it, be paranoid. Assume attackers will try everything.

Your users trust you with their data. Don’t break that trust.

Stay secure!