Introduction to Authentication
Authentication is the process of verifying that users are who they claim to be. It's the digital equivalent of checking someone's ID at the door before letting them into a restricted area. In today's interconnected world, robust authentication is the foundation of security for web applications and APIs.
When we talk about authentication, we're addressing three primary questions:
- Identity: Who is the user?
- Proof: How can they prove it?
- Persistence: How do we remember they've authenticated?
Think of authentication like entering a country: your passport establishes your identity, the immigration officer verifies it's really you, and then you receive a visa or entry stamp that allows you to remain in the country for a specified period without having to verify your identity again at every internal checkpoint.
Authentication Factors
Authentication factors are categories of credentials used to verify identity. Modern systems often combine multiple factors for enhanced security.
Something You Know
Knowledge-based authentication relies on information only the user should know.
- Passwords and passphrases
- PINs (Personal Identification Numbers)
- Security questions (though these are increasingly considered problematic)
Something You Have
Possession-based authentication requires the user to have physical access to an authentication device.
- Mobile phones (for SMS codes or authenticator apps)
- Hardware tokens (YubiKey, RSA SecurID)
- Smart cards
- Email accounts (for magic links or verification codes)
Something You Are
Biometric authentication verifies physical or behavioral characteristics.
- Fingerprints
- Facial recognition
- Voice patterns
- Typing patterns
- Retina or iris scans
Somewhere You Are
Location-based authentication validates the user's geographic position.
- GPS location
- Network location (IP address)
- Geofencing
The bank ATM is a classic real-world example of multi-factor authentication: you need both your bank card (something you have) and your PIN (something you know) to access your account.
Password-Based Authentication
Despite their limitations, passwords remain the most common authentication mechanism. Let's examine how modern password-based authentication should be implemented.
Password Storage
Never store passwords in plain text! Always use a secure one-way hashing algorithm with a unique salt for each user.
// Node.js example using bcrypt
const bcrypt = require('bcrypt');
const saltRounds = 12; // Higher is more secure but slower
async function storePassword(password) {
try {
// bcrypt automatically generates a salt and combines it with the hash
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Store hashedPassword in your database
return hashedPassword;
} catch (error) {
console.error('Error hashing password:', error);
throw error;
}
}
async function verifyPassword(password, storedHash) {
try {
// bcrypt will extract the salt from the stored hash
// and use it to hash the provided password for comparison
const match = await bcrypt.compare(password, storedHash);
return match; // true if passwords match, false otherwise
} catch (error) {
console.error('Error verifying password:', error);
throw error;
}
}
Think of password hashing like a one-way meat grinder: you can turn a steak into ground beef, but you can't reconstruct the original steak from the ground meat. Similarly, a properly hashed password can't be reversed to reveal the original password.
Password Strength Requirements
- Minimum length (at least 8-12 characters)
- Complexity (mix of character types)
- Avoiding common passwords
- Checking against breach databases
// Example password strength validation using zxcvbn
const zxcvbn = require('zxcvbn');
function validatePasswordStrength(password, userInputs = []) {
// userInputs should include data like username, email, etc.
const result = zxcvbn(password, userInputs);
// Score ranges from 0 (very weak) to 4 (very strong)
if (result.score < 3) {
return {
valid: false,
message: result.feedback.warning || 'Password is too weak',
suggestions: result.feedback.suggestions
};
}
return { valid: true };
}
Session-Based Authentication
Session-based authentication is a traditional model where the server maintains the user's authentication state.
Session Workflow
- User provides credentials
- Server validates credentials
- Server creates a unique session identifier
- Server stores session data (in memory, database, or distributed cache)
- Server sends session ID to client (usually as a cookie)
- Client includes session ID with subsequent requests
- Server validates session ID and retrieves session data
- When user logs out, server invalidates the session
Express.js Session Implementation
const express = require('express');
const session = require('express-session');
const redis = require('redis');
const RedisStore = require('connect-redis')(session);
const app = express();
// Create Redis client
const redisClient = redis.createClient({
host: 'localhost',
port: 6379
});
// Configure session middleware
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: 'your-secure-session-secret',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
httpOnly: true, // Prevent JavaScript access
maxAge: 1000 * 60 * 60 * 24 // 24 hours
}
}));
// Login route
app.post('/login', async (req, res) => {
const { username, password } = req.body;
try {
// Validate credentials (implementation details omitted)
const user = await validateUser(username, password);
if (user) {
// Store user info in session
req.session.userId = user.id;
req.session.role = user.role;
// Regenerate session ID to prevent session fixation
req.session.regenerate((err) => {
if (err) {
console.error('Session regeneration error:', err);
return res.status(500).json({ error: 'Authentication error' });
}
res.json({ message: 'Login successful' });
});
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Protected route
app.get('/profile', (req, res) => {
// Check if user is authenticated
if (!req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
// Fetch and return user profile
// ...
});
// Logout route
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('Session destruction error:', err);
return res.status(500).json({ error: 'Logout error' });
}
res.clearCookie('connect.sid');
res.json({ message: 'Logout successful' });
});
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
Advantages of Session-Based Authentication
- Server has complete control over sessions
- Can immediately invalidate sessions if needed
- Session expiration is server-controlled
- Works well with traditional server-rendered applications
Disadvantages of Session-Based Authentication
- Sessions consume server memory or database space
- Scaling requires session sharing between servers
- CSRF (Cross-Site Request Forgery) vulnerabilities to consider
- Not ideal for stateless APIs or microservices
Think of session-based authentication like a concert wristband: once you show your ticket (credentials) at the entrance, you get a wristband (session cookie) that lets you enter and exit the venue without showing your ticket again during the event.
Token-Based Authentication
Token-based authentication is a stateless model where the client stores the authentication state in a secure token.
Token Workflow
- User provides credentials
- Server validates credentials
- Server generates a signed token containing user identity and permissions
- Server sends the token to the client
- Client stores the token (localStorage, sessionStorage, or memory)
- Client includes token with subsequent requests (usually in Authorization header)
- Server verifies token signature and extracts user information
- When user logs out, client discards the token
Popular Token Formats
- JWT (JSON Web Tokens): Self-contained tokens with encoded JSON payloads
- Opaque Tokens: Random strings that reference server-side stored data
- PASETO (Platform-Agnostic Security Tokens): Modern alternative to JWT with stronger security properties
JWT Structure
- Header: Specifies token type and algorithm
- Payload: Contains claims (user data, permissions, expiration)
- Signature: Ensures the token hasn't been tampered with
Implementing JWT Authentication
// Node.js example using Express and jsonwebtoken
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET || 'your-secure-jwt-secret';
const JWT_EXPIRES_IN = '1h';
// Login route
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
try {
// Find user (implementation details omitted)
const user = await findUserByUsername(username);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const passwordValid = await bcrypt.compare(password, user.password);
if (!passwordValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate JWT
const token = jwt.sign(
{
userId: user.id,
username: user.username,
role: user.role
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
// Return token to client
res.json({
message: 'Login successful',
token,
expiresIn: JWT_EXPIRES_IN
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Authentication middleware
function authenticateToken(req, res, next) {
// Get token from Authorization header
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
// Verify token
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) {
console.error('Token verification error:', err);
return res.status(403).json({ error: 'Invalid or expired token' });
}
// Add user info to request object
req.user = decoded;
next();
});
}
// Protected route
app.get('/api/profile', authenticateToken, (req, res) => {
// req.user contains the decoded JWT payload
res.json({
userId: req.user.userId,
username: req.user.username,
role: req.user.role
});
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
Advantages of Token-Based Authentication
- Stateless - no session storage required on the server
- Scalable - works well with distributed systems and microservices
- Cross-domain - can be used across different domains
- Mobile-friendly - works well with native mobile applications
Disadvantages of Token-Based Authentication
- Tokens can't be invalidated before they expire
- Larger request size due to token inclusion
- Sensitive data in token (even if encoded) may pose security risks
- Client-side storage concerns (XSS vulnerabilities)
Think of token-based authentication like a temporary ID badge with your photo, name, and access levels printed on it. Once issued, the badge can be visually inspected at each checkpoint without needing to contact the central security office, but it can't be remotely deactivated if lost.
Refresh Token Pattern
The refresh token pattern improves security by using short-lived access tokens alongside longer-lived refresh tokens.
Implementation
// Token generation during login
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
try {
// Verify credentials (implementation omitted)
const user = await validateUser(username, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate access token (short-lived)
const accessToken = jwt.sign(
{
userId: user.id,
username: user.username,
role: user.role
},
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' } // Short lifespan
);
// Generate refresh token (long-lived)
const refreshToken = crypto.randomBytes(40).toString('hex');
const refreshTokenExpiry = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
// Store refresh token in database
await storeRefreshToken({
token: refreshToken,
userId: user.id,
expiresAt: refreshTokenExpiry,
userAgent: req.headers['user-agent'],
ipAddress: req.ip
});
// Return both tokens
res.json({
accessToken,
refreshToken,
expiresIn: '15m'
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Token refresh endpoint
app.post('/api/refresh-token', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token required' });
}
try {
// Find refresh token in database
const storedToken = await findRefreshToken(refreshToken);
if (!storedToken || new Date() > storedToken.expiresAt) {
return res.status(401).json({ error: 'Invalid or expired refresh token' });
}
// Get user associated with token
const user = await findUserById(storedToken.userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
// Generate new access token
const accessToken = jwt.sign(
{
userId: user.id,
username: user.username,
role: user.role
},
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
// Return new access token
res.json({
accessToken,
expiresIn: '15m'
});
} catch (error) {
console.error('Token refresh error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Logout (invalidate refresh token)
app.post('/api/logout', authenticateToken, async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token required' });
}
try {
// Remove refresh token from database
await removeRefreshToken(refreshToken);
res.json({ message: 'Logout successful' });
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
Security Considerations
- Store refresh tokens securely (hashed in the database)
- Implement token rotation (issue a new refresh token with each use)
- Associate refresh tokens with device/IP information
- Implement an absolute limit on refresh token lifetime
- Provide a way to revoke all refresh tokens for a user
The refresh token pattern is like having a hotel key card (access token) that expires daily, but as long as you have your valid reservation confirmation (refresh token), you can get a new key card from the front desk without having to check in again.
Multi-Factor Authentication (MFA)
Multi-factor authentication significantly increases security by requiring users to verify their identity through multiple independent channels.
Common Second Factors
- SMS/Email Codes: One-time codes sent via text or email
- TOTP (Time-based One-Time Password): Generated by authenticator apps
- Push Notifications: Approve login requests via a trusted device
- Hardware Tokens: Physical devices generating codes or using FIDO standards
- Biometrics: Fingerprint, face recognition, etc.
Implementing TOTP-Based MFA
// Node.js example using speakeasy for TOTP
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Generate secret during MFA setup
app.post('/api/setup-mfa', authenticateToken, async (req, res) => {
try {
// Generate a secret key
const secret = speakeasy.generateSecret({
name: `MyApp:${req.user.username}`
});
// Store secret key in user's record (temporarily)
// In production, encrypt this before storage
await updateUserMfaSecret(req.user.userId, secret.base32);
// Generate QR code for easy setup with authenticator apps
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({
message: 'MFA setup initiated',
secret: secret.base32, // Only show during setup
qrCodeUrl
});
} catch (error) {
console.error('MFA setup error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Verify and enable MFA
app.post('/api/verify-mfa', authenticateToken, async (req, res) => {
const { token } = req.body;
try {
// Get user's secret
const user = await findUserById(req.user.userId);
if (!user.mfaSecret) {
return res.status(400).json({ error: 'MFA not set up' });
}
// Verify the token
const verified = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: token,
window: 1 // Allow 1 step before/after for clock drift
});
if (!verified) {
return res.status(401).json({ error: 'Invalid MFA token' });
}
// Enable MFA for the user
await updateUserMfaEnabled(req.user.userId, true);
res.json({ message: 'MFA enabled successfully' });
} catch (error) {
console.error('MFA verification error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Login with MFA
app.post('/api/login', async (req, res) => {
const { username, password, mfaToken } = req.body;
try {
// Validate credentials
const user = await validateUser(username, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Check if MFA is enabled
if (user.mfaEnabled) {
// If MFA token not provided in initial request
if (!mfaToken) {
return res.status(200).json({
message: 'MFA required',
requireMfa: true,
userId: user.id // Include minimal info needed for MFA step
});
}
// Verify MFA token
const verified = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: mfaToken,
window: 1
});
if (!verified) {
return res.status(401).json({ error: 'Invalid MFA token' });
}
}
// At this point, user is authenticated (with MFA if required)
// Generate tokens as usual...
res.json({
message: 'Login successful',
accessToken,
refreshToken
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
Mobile and API Authentication
Mobile applications and APIs require special consideration for authentication flows.
Mobile-Specific Considerations
- Longer session lifetimes (users don't want to log in frequently)
- Secure storage for tokens (Keychain on iOS, Keystore on Android)
- Biometric authentication integration
- Handling app background/foreground transitions
- Device binding (tying authentication to specific devices)
API Authentication Best Practices
- Use HTTPS exclusively
- Implement rate limiting to prevent brute force attacks
- Consider API keys for server-to-server communication
- Implement proper CORS headers
- Use scoped tokens with least privilege
Native App Authentication Flow
// Swift example for secure token storage on iOS
import Foundation
import Security
class KeychainService {
static func saveToken(_ token: String, forKey key: String) -> Bool {
let tokenData = token.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: tokenData,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
// Delete any existing token
SecItemDelete(query as CFDictionary)
// Add the new token
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
static func getToken(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let tokenData = result as? Data,
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return token
}
static func deleteToken(forKey key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess
}
}
// Usage
class AuthService {
private let accessTokenKey = "com.myapp.accessToken"
private let refreshTokenKey = "com.myapp.refreshToken"
func saveTokens(accessToken: String, refreshToken: String) {
KeychainService.saveToken(accessToken, forKey: accessTokenKey)
KeychainService.saveToken(refreshToken, forKey: refreshTokenKey)
}
func getAccessToken() -> String? {
return KeychainService.getToken(forKey: accessTokenKey)
}
func getRefreshToken() -> String? {
return KeychainService.getToken(forKey: refreshTokenKey)
}
func clearTokens() {
KeychainService.deleteToken(forKey: accessTokenKey)
KeychainService.deleteToken(forKey: refreshTokenKey)
}
}
Social Authentication
Social authentication allows users to log in using their existing accounts from social platforms.
Benefits
- Reduced friction during registration and login
- Leverages the security of established providers
- Access to additional profile information
- Potential for social features and sharing
Challenges
- Dependency on third-party services
- Privacy considerations
- Account linking complexity
- Need to handle provider-specific peculiarities
Implementation with Passport.js
const express = require('express');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const session = require('express-session');
const app = express();
// Configure session
app.use(session({
secret: 'your-session-secret',
resave: false,
saveUninitialized: false
}));
// Initialize Passport
app.use(passport.initialize());
app.use(passport.session());
// Configure Google Strategy
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/auth/google/callback'
},
async (accessToken, refreshToken, profile, done) => {
try {
// Find or create user based on Google profile
let user = await findUserByProviderId('google', profile.id);
if (!user) {
// Create new user if not found
user = await createUser({
provider: 'google',
providerId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
picture: profile.photos[0].value
});
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// Serialize/deserialize user for session
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await findUserById(id);
done(null, user);
} catch (error) {
done(error);
}
});
// Routes for Google authentication
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
// Successful authentication
res.redirect('/dashboard');
}
);
// Check if user is authenticated
function isAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.redirect('/login');
}
// Protected route
app.get('/dashboard', isAuthenticated, (req, res) => {
res.json({ user: req.user });
});
// Logout
app.get('/logout', (req, res) => {
req.logout();
res.redirect('/');
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
Security Considerations
Authentication security requires attention to several key areas:
Common Attack Vectors
- Brute Force Attacks: Implement rate limiting and account lockouts
- Credential Stuffing: Check for compromised passwords
- Session Hijacking: Use secure cookies and HTTPS
- Cross-Site Scripting (XSS): Use HttpOnly cookies and Content Security Policy
- Cross-Site Request Forgery (CSRF): Implement anti-CSRF tokens
- Man-in-the-Middle: Always use HTTPS
Best Practices
- Implement secure password reset flows
- Use proper password storage (bcrypt, Argon2)
- Implement account lockout policies
- Create detailed security logs
- Set appropriate token expiration times
- Consider implementing MFA for all users
- Regularly audit authentication systems
- Keep dependencies updated
Rate Limiting Implementation
// Express rate limiting example using express-rate-limit
const rateLimit = require('express-rate-limit');
// Create limiter for login attempts
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window
message: {
error: 'Too many login attempts. Please try again after 15 minutes.'
},
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false // Disable the `X-RateLimit-*` headers
});
// Apply rate limiting to login route
app.post('/api/login', loginLimiter, async (req, res) => {
// Login logic...
});
// Create a more lenient limiter for general API requests
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: {
error: 'Too many requests. Please try again later.'
}
});
// Apply to all API routes
app.use('/api/', apiLimiter);
Authentication Workflows for Different Application Types
Single-Page Applications (SPAs)
- Typically use token-based auth (JWT)
- Store tokens in memory (or secure cookies)
- Implement token refresh mechanism
- Consider silent authentication with refresh tokens
Server-Rendered Web Applications
- Session-based authentication works well
- HttpOnly cookies for session identifiers
- CSRF protection important
Mobile Applications
- Token-based authentication
- Secure storage using platform keychain/keystore
- Consider device binding
- Biometric authentication integration
Microservices
- JWT or opaque tokens
- API gateways for authentication
- Service-to-service authentication
- Consider zero-trust security model
IoT Devices
- Device certificates
- OAuth 2.0 device flow
- Long-lived credentials with rotation
Practical Activities
Activity 1: Implement Password Authentication
Create a basic Express application with secure password-based authentication:
- Set up an Express server with MongoDB or another database
- Create user registration and login endpoints
- Implement proper password hashing with bcrypt
- Add session-based authentication
- Create protected routes that require authentication
- Implement logout functionality
Activity 2: Token-Based Authentication
Modify your application to use JWT authentication:
- Replace session-based auth with JWT tokens
- Create an authentication middleware to validate tokens
- Implement the refresh token pattern
- Add token blacklisting for logout
- Test with a simple frontend or API client
Activity 3: Multi-Factor Authentication
Add MFA to your authentication system:
- Integrate a TOTP library like speakeasy
- Create endpoints for MFA setup and verification
- Generate QR codes for easy setup
- Modify the login flow to require MFA when enabled
- Add recovery codes for backup access
Activity 4: Social Authentication
Add social login to your application:
- Register your app with Google, Facebook, or another provider
- Integrate Passport.js with the appropriate strategies
- Implement the OAuth flow
- Handle user creation/linking when social users login
- Add profile information synchronization
Additional Resources
Books and Documentation
- JWT Handbook by Auth0
- OAuth 2.0 Framework (RFC 6749)
- JSON Web Token (RFC 7519)
- MDN HTTP Authentication