AboutBlogContact
SecurityJanuary 8, 2026 9 min read 10

Authentication in 2026: JWT, OAuth 2.0, Passkeys, and When to Use Each

AunimedaAunimeda
📋 Table of Contents

Authentication in 2026: JWT, OAuth 2.0, Passkeys, and When to Use Each

Authentication is the part of every application where developers most frequently introduce security vulnerabilities. Not because the concepts are new — session-based auth has existed since the 90s — but because there are too many options, bad tutorials, and security subtleties that don't appear until production.

This is a practical guide to the authentication mechanisms you'll encounter in 2026, when to use each, and the pitfalls to avoid.


The options in 2026

  • Session + cookie — classic, server-side, still correct for many applications
  • JWT (JSON Web Tokens) — stateless tokens, widely used, frequently misused
  • OAuth 2.0 — delegation protocol, for "Login with Google" and API authorization
  • Passkeys (WebAuthn) — hardware-based, phishing-resistant, the modern standard
  • API Keys — for machine-to-machine authentication

Session + cookie: the underrated default

Sessions stored server-side with a cookie are still the right choice for most web applications in 2026. Not glamorous. Not the subject of conference talks. Correct.

// Express + express-session
const session = require('express-session');
const RedisStore = require('connect-redis')(session);

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,   // Not accessible to JavaScript
    secure: true,     // HTTPS only
    sameSite: 'lax',  // CSRF protection
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  }
}));

// Login
app.post('/login', async (req, res) => {
  const user = await User.findByEmail(req.body.email);
  if (!user || !await bcrypt.compare(req.body.password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  req.session.userId = user.id;
  req.session.save();
  res.json({ success: true });
});

// Authenticated request
app.get('/profile', requireAuth, async (req, res) => {
  const user = await User.findById(req.session.userId);
  res.json(user);
});

function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  next();
}

Advantages:

  • Instant logout (delete the session from Redis)
  • No token leakage — cookie is HttpOnly, not readable by JavaScript
  • No token expiry management on the client
  • Server controls session validity completely

When to use: Traditional web apps, admin panels, any application where the frontend and backend are on the same domain.

When not to use: Mobile apps (cookies are awkward in mobile clients), cross-domain APIs, third-party integrations.


JWT: powerful, but easy to get wrong

A JWT is a self-contained token containing encoded claims (user ID, roles, expiry) signed with a secret. The server can verify the token without a database lookup because the signature proves it was issued by the server.

const jwt = require('jsonwebtoken');

// Issue token at login
const token = jwt.sign(
  { 
    sub: user.id,          // subject (user ID)
    email: user.email,
    role: user.role,
    iat: Math.floor(Date.now() / 1000), // issued at
  },
  process.env.JWT_SECRET,
  { expiresIn: '15m' } // short-lived access token
);

// Verify token
function verifyToken(token) {
  try {
    return jwt.verify(token, process.env.JWT_SECRET);
  } catch (err) {
    return null;
  }
}

// Middleware
function requireAuth(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }
  
  const token = authHeader.substring(7);
  const payload = verifyToken(token);
  
  if (!payload) {
    return res.status(401).json({ error: 'Invalid token' });
  }
  
  req.user = payload;
  next();
}

The refresh token pattern

Access tokens must be short-lived (15 minutes) to limit damage if stolen. But short-lived tokens mean users need to re-login frequently. The solution: separate access and refresh tokens.

// Login: issue both tokens
async function login(email, password) {
  const user = await authenticateUser(email, password);
  
  const accessToken = jwt.sign(
    { sub: user.id, role: user.role },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: '15m' }
  );
  
  const refreshToken = jwt.sign(
    { sub: user.id, tokenFamily: uuid() },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '30d' }
  );
  
  // Store refresh token hash in database
  await RefreshToken.create({
    userId: user.id,
    tokenHash: hashToken(refreshToken),
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  });
  
  return { accessToken, refreshToken };
}

// Refresh: exchange refresh token for new access token
async function refreshTokens(refreshToken) {
  const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
  
  const stored = await RefreshToken.findByHash(hashToken(refreshToken));
  if (!stored || stored.userId !== payload.sub) {
    throw new Error('Invalid refresh token');
  }
  
  // Rotate: delete old, issue new (prevents token reuse)
  await RefreshToken.delete(stored.id);
  
  const newAccessToken = jwt.sign(
    { sub: payload.sub },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: '15m' }
  );
  
  const newRefreshToken = jwt.sign(
    { sub: payload.sub, tokenFamily: uuid() },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '30d' }
  );
  
  await RefreshToken.create({
    userId: payload.sub,
    tokenHash: hashToken(newRefreshToken),
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  });
  
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Common JWT mistakes

Storing JWTs in localStorage. localStorage is accessible to any JavaScript on the page. If there's an XSS vulnerability anywhere on your domain, an attacker can steal the token. Store access tokens in memory (JavaScript variable), refresh tokens in HttpOnly cookies.

Long-lived access tokens. A 24-hour or 7-day access token cannot be revoked without a token blacklist (which eliminates the statelessness benefit of JWT). 15-minute access tokens + refresh token rotation is the correct pattern.

Using HS256 with a weak secret. The secret must be long and random. JWT_SECRET=mysecret in a .env file is not acceptable. Use at least 256 bits of entropy: openssl rand -hex 32.

Not validating the algorithm. Old JWT libraries had a vulnerability where changing the alg header to none bypassed verification. Modern libraries (jsonwebtoken 9+) reject none by default. Always pin the expected algorithm explicitly:

jwt.verify(token, secret, { algorithms: ['HS256'] });

When to use JWT: Mobile apps (cookies are awkward), microservices where each service needs to verify identity without a shared session store, API authentication, cross-domain authentication.


OAuth 2.0: delegation and "Login with X"

OAuth 2.0 is an authorization protocol, not an authentication protocol. It answers: "Can user X authorize application Y to access resource Z on their behalf?" The "Login with Google" use case layered OpenID Connect (OIDC) on top of OAuth 2.0 to also answer: "Who is this user?"

The Authorization Code Flow (correct flow for web apps)

1. User clicks "Login with Google"
2. App redirects to Google with:
   - client_id (your app's ID)
   - redirect_uri (callback URL)
   - scope (what you want: openid, email, profile)
   - state (random value to prevent CSRF)
   - code_challenge (PKCE, for security)

3. User logs into Google, approves permissions
4. Google redirects back to redirect_uri with:
   - code (one-time authorization code)
   - state (verify this matches what you sent)

5. Your server exchanges code for tokens:
   POST https://oauth2.googleapis.com/token
   {
     code, client_id, client_secret, redirect_uri,
     code_verifier (PKCE)
   }
   
6. Google returns:
   - access_token (to call Google APIs on user's behalf)
   - id_token (JWT containing user info — OpenID Connect)
   - refresh_token (if you requested offline access)

7. Your server verifies id_token, extracts user info
8. Create or find user in your database
9. Start a session (or issue your own JWT)

With Passport.js:

const passport = require('passport');
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');

passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  callbackURL: '/auth/google/callback',
}, async (accessToken, refreshToken, profile, done) => {
  // profile.id is the stable Google user ID
  let user = await User.findByGoogleId(profile.id);
  
  if (!user) {
    user = await User.create({
      googleId: profile.id,
      email: profile.emails[0].value,
      name: profile.displayName,
      avatar: profile.photos[0].value,
    });
  }
  
  return done(null, user);
}));

app.get('/auth/google', passport.authenticate('google', { scope: ['openid', 'email', 'profile'] }));

app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login' }),
  (req, res) => {
    res.redirect('/dashboard');
  }
);

When to use OAuth 2.0 / OIDC:

  • Social login ("Login with Google/Apple/GitHub")
  • Your app needs to call third-party APIs on behalf of the user
  • Building an authorization server for your own platform

Passkeys (WebAuthn): the 2026 standard

Passkeys are the authentication method that eliminates passwords entirely. Instead of a password, the user authenticates with a biometric (Face ID, Touch ID, Windows Hello) or hardware key. The private key never leaves the device. Phishing is cryptographically impossible.

Browser and OS support is now comprehensive: Chrome, Safari, Firefox, iOS, Android, Windows. If you're building a new application in 2026, Passkeys should be your primary authentication method.

Registration

// 1. Server generates challenge
app.post('/auth/passkey/register/begin', requireAuth, async (req, res) => {
  const user = await User.findById(req.session.userId);
  
  const options = await generateRegistrationOptions({
    rpName: 'My App',
    rpID: 'myapp.com',
    userID: user.id,
    userName: user.email,
    userDisplayName: user.name,
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'required',
      userVerification: 'required',
    },
  });
  
  // Store challenge in session for verification
  req.session.registrationChallenge = options.challenge;
  res.json(options);
});

// 2. Browser calls navigator.credentials.create() with options
// 3. User approves with biometric
// 4. Browser sends credential to server

app.post('/auth/passkey/register/verify', requireAuth, async (req, res) => {
  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge: req.session.registrationChallenge,
    expectedOrigin: 'https://myapp.com',
    expectedRPID: 'myapp.com',
  });
  
  if (verification.verified) {
    await Passkey.create({
      userId: req.session.userId,
      credentialID: verification.registrationInfo.credentialID,
      credentialPublicKey: verification.registrationInfo.credentialPublicKey,
      counter: verification.registrationInfo.counter,
    });
    res.json({ success: true });
  }
});

Authentication

// 1. Server generates authentication options
app.post('/auth/passkey/login/begin', async (req, res) => {
  const options = await generateAuthenticationOptions({
    rpID: 'myapp.com',
    userVerification: 'required',
  });
  
  req.session.authChallenge = options.challenge;
  res.json(options);
});

// 2. Browser calls navigator.credentials.get()
// 3. User approves with biometric
// 4. Browser sends signed assertion to server

app.post('/auth/passkey/login/verify', async (req, res) => {
  const passkey = await Passkey.findByCredentialId(req.body.id);
  if (!passkey) return res.status(401).json({ error: 'Unknown credential' });
  
  const verification = await verifyAuthenticationResponse({
    response: req.body,
    expectedChallenge: req.session.authChallenge,
    expectedOrigin: 'https://myapp.com',
    expectedRPID: 'myapp.com',
    authenticator: {
      credentialID: passkey.credentialID,
      credentialPublicKey: passkey.credentialPublicKey,
      counter: passkey.counter,
    },
  });
  
  if (verification.verified) {
    // Update counter (prevents replay attacks)
    await Passkey.updateCounter(passkey.id, verification.authenticationInfo.newCounter);
    
    req.session.userId = passkey.userId;
    res.json({ success: true });
  }
});

API Keys: machine-to-machine

For server-to-server communication or developer API access, use API keys. Not JWTs, not OAuth — simple opaque tokens associated with an entity in your database.

// Generate API key
const { randomBytes } = require('crypto');

async function createApiKey(userId, name) {
  const key = `ak_${randomBytes(32).toString('hex')}`;
  const keyHash = hashKey(key); // Store hash, not plaintext
  
  await ApiKey.create({
    userId,
    name,
    keyHash,
    lastUsedAt: null,
    createdAt: new Date(),
  });
  
  // Return plaintext key once — never stored or shown again
  return key;
}

// Authenticate API request
async function authenticateApiKey(key) {
  if (!key?.startsWith('ak_')) return null;
  
  const keyHash = hashKey(key);
  const apiKey = await ApiKey.findByHash(keyHash);
  
  if (!apiKey) return null;
  
  // Update last used (async, don't block the request)
  ApiKey.updateLastUsed(apiKey.id).catch(console.error);
  
  return await User.findById(apiKey.userId);
}

Decision matrix

Scenario Recommendation
Web app, same-domain frontend Session + cookie
Mobile app JWT (access + refresh)
"Login with Google/Apple" OAuth 2.0 + OIDC
New consumer app in 2026 Passkeys + fallback
API accessed by developers API keys
Microservices JWT (service-to-service)
Admin panel Session + cookie + 2FA
High-security (banking, health) Passkeys + hardware token

Most applications in 2026 should implement: Passkeys as primary, email+password as fallback (with bcrypt, rate limiting), and social login (Google/Apple) for convenience. JWT for mobile apps. API keys for developer integrations.

Do not build your own cryptography. Do not store plaintext passwords. Do not use MD5 or SHA1 for password hashing (use bcrypt, argon2, or scrypt). Do not store JWTs in localStorage. These are not advanced recommendations — they are table stakes.

Read Also

OWASP Top 10 2025: Web Application Security Guide with Real Attack Examplesaunimeda
Security

OWASP Top 10 2025: Web Application Security Guide with Real Attack Examples

The OWASP Top 10 2025 lists the most critical web application security risks. This is not theory — each vulnerability includes a real attack example, how it works in your Node.js/React codebase, and the concrete fix.

Web App Security Checklist for 2026 - What Every Developer Must Knowaunimeda
Security

Web App Security Checklist for 2026 - What Every Developer Must Know

90% of web app breaches are preventable. This checklist covers the OWASP Top 10, authentication hardening, and the specific misconfigurations we see in audits repeatedly.

OpenSSH Tunneling: Securing Database Connections Over Public Networks (2006)aunimeda
Security

OpenSSH Tunneling: Securing Database Connections Over Public Networks (2006)

Exposing MySQL or PostgreSQL to the public internet is a recipe for disaster. In 2006, SSH tunneling is the gold standard for secure remote database administration.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Get Consultation All articles