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.