DEV Community

Cover image for Secure Web Authentication in 2023: Balancing Security and User Experience
Aarav Joshi
Aarav Joshi

Posted on

Secure Web Authentication in 2023: Balancing Security and User Experience

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Modern web authentication has evolved significantly in recent years, with security becoming paramount as cyber threats continue to grow in sophistication. As developers and security professionals, we must stay ahead of these threats by implementing robust authentication mechanisms.

I've spent years implementing various authentication systems across different platforms, and I've learned that security doesn't have to come at the expense of user experience. In fact, the most effective authentication systems balance both seamlessly.

Multi-factor Authentication

Multi-factor authentication (MFA) has become standard practice for securing user accounts. By requiring users to verify their identity through multiple methods, we create significant barriers for attackers.

Time-based one-time passwords (TOTP) remain one of the most effective MFA methods. The algorithm generates temporary codes that expire after a short period, typically 30 seconds.

// Server-side TOTP verification example
const speakeasy = require('speakeasy');

function verifyTOTP(userSecret, providedToken) {
  return speakeasy.totp.verify({
    secret: userSecret,
    encoding: 'base32',
    token: providedToken,
    window: 1 // Allow 1 period before/after for clock drift
  });
}

// Usage
const isValid = verifyTOTP('JBSWY3DPEHPK3PXP', '123456');
console.log(isValid); // true or false
Enter fullscreen mode Exit fullscreen mode

Push notifications have also gained popularity as an MFA method. They provide better user experience while maintaining security.

// Server-side push notification for authentication
async function sendAuthPushNotification(userId, authRequestId) {
  const user = await getUserById(userId);

  return await pushService.send({
    token: user.deviceToken,
    payload: {
      type: 'auth_request',
      requestId: authRequestId,
      expiresAt: Date.now() + 120000 // 2 minutes
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Passwordless Authentication

Password-based authentication is increasingly becoming obsolete due to its inherent vulnerabilities. Passwordless methods provide stronger security and improved user experience.

Magic links are a simple yet effective passwordless method. When a user attempts to log in, we send them an email with a special link containing a secure token.

// Generate and send a magic link
async function sendMagicLink(email) {
  const token = generateSecureToken();
  const expiresAt = new Date(Date.now() + 3600000); // 1 hour

  await db.tokens.create({
    email,
    token: await bcrypt.hash(token, 10),
    expiresAt
  });

  const magicLink = `https://example.com/auth/verify?token=${token}&email=${encodeURIComponent(email)}`;

  await sendEmail({
    to: email,
    subject: 'Your login link',
    body: `Click here to log in: ${magicLink}`
  });
}
Enter fullscreen mode Exit fullscreen mode

WebAuthn represents the future of authentication, leveraging hardware security keys or biometric authentication built into devices.

// WebAuthn registration example
async function registerWebAuthn(username) {
  // Generate a random user ID
  const userId = new Uint8Array(16);
  window.crypto.getRandomValues(userId);

  // Get challenge from server
  const response = await fetch('/auth/webauthn/challenge');
  const { challenge } = await response.json();

  // Create credentials
  const credential = await navigator.credentials.create({
    publicKey: {
      challenge: base64ToArrayBuffer(challenge),
      rp: {
        name: 'Example App',
        id: window.location.hostname
      },
      user: {
        id: userId,
        name: username,
        displayName: username
      },
      pubKeyCredParams: [
        { type: 'public-key', alg: -7 }, // ES256
        { type: 'public-key', alg: -257 } // RS256
      ],
      timeout: 60000,
      attestation: 'direct'
    }
  });

  // Send credential to server
  await fetch('/auth/webauthn/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      id: credential.id,
      rawId: arrayBufferToBase64(credential.rawId),
      response: {
        attestationObject: arrayBufferToBase64(credential.response.attestationObject),
        clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON)
      },
      type: credential.type
    })
  });
}
Enter fullscreen mode Exit fullscreen mode

JSON Web Tokens (JWT)

JWTs provide a compact and self-contained way to securely transmit information between parties. They're particularly useful for stateless authentication.

// Creating and signing a JWT on the server
const jwt = require('jsonwebtoken');

function generateAuthToken(userId, userRole) {
  const payload = {
    sub: userId,
    role: userRole,
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + (15 * 60) // 15 minutes
  };

  return jwt.sign(payload, process.env.JWT_SECRET_KEY, {
    algorithm: 'HS256'
  });
}

// Verifying a JWT
function verifyAuthToken(token) {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET_KEY, {
      algorithms: ['HS256']
    });
    return { valid: true, payload };
  } catch (error) {
    return { valid: false, error: error.message };
  }
}
Enter fullscreen mode Exit fullscreen mode

When implementing JWT authentication, I've found these best practices essential:

  1. Keep tokens short-lived (15-30 minutes)
  2. Use refresh tokens for obtaining new access tokens
  3. Store tokens securely (HttpOnly cookies for web applications)
  4. Implement token revocation mechanisms

OAuth 2.0 with PKCE

OAuth 2.0 with Proof Key for Code Exchange (PKCE) has become the standard for secure authorization, especially for mobile and single-page applications.

// Client-side PKCE implementation
async function initiateOAuthWithPKCE() {
  // Generate code verifier (random string between 43-128 chars)
  const codeVerifier = generateRandomString(64);

  // Store code verifier in local storage
  localStorage.setItem('code_verifier', codeVerifier);

  // Generate code challenge (SHA-256 hash of verifier)
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const digest = await window.crypto.subtle.digest('SHA-256', data);

  // Convert digest to base64url
  const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  // Redirect to authorization endpoint
  const authUrl = new URL('https://auth.example.com/oauth/authorize');
  authUrl.searchParams.append('client_id', 'YOUR_CLIENT_ID');
  authUrl.searchParams.append('redirect_uri', 'https://yourapp.com/callback');
  authUrl.searchParams.append('response_type', 'code');
  authUrl.searchParams.append('scope', 'read write');
  authUrl.searchParams.append('code_challenge', codeChallenge);
  authUrl.searchParams.append('code_challenge_method', 'S256');

  window.location.href = authUrl.toString();
}

// Exchanging authorization code for tokens
async function exchangeCodeForTokens(authorizationCode) {
  const codeVerifier = localStorage.getItem('code_verifier');

  const response = await fetch('https://auth.example.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: 'YOUR_CLIENT_ID',
      code: authorizationCode,
      redirect_uri: 'https://yourapp.com/callback',
      code_verifier: codeVerifier
    })
  });

  const tokens = await response.json();
  return tokens;
}
Enter fullscreen mode Exit fullscreen mode

Secure Session Management

Proper session management is crucial for maintaining authenticated states securely.

// Express.js secure session setup
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');

const app = express();
const redisClient = redis.createClient({
  url: process.env.REDIS_URL
});

redisClient.connect().catch(console.error);

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  name: '__Secure-Session', // Custom cookie name
  cookie: {
    maxAge: 3600000, // 1 hour
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    domain: process.env.COOKIE_DOMAIN
  },
  resave: false,
  saveUninitialized: false
}));
Enter fullscreen mode Exit fullscreen mode

For cookie-based authentication, I always ensure cookies are configured with appropriate security attributes:

  1. HttpOnly to prevent JavaScript access
  2. Secure flag to restrict transmission to HTTPS
  3. SameSite attribute to prevent CSRF attacks
  4. Short expiration times
  5. Specific domain scoping

Rate Limiting and Brute Force Protection

Implementing rate limiting is essential to protect authentication endpoints from brute force attacks.

// Express.js rate limiting middleware
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');

// Global rate limiter
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args)
  })
});

// Stricter login endpoint limiter
const loginLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 5, // 5 login attempts per hour
  message: 'Too many login attempts, please try again after an hour',
  standardHeaders: true,
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args)
  })
});

app.use(globalLimiter);
app.post('/api/login', loginLimiter, loginController);
Enter fullscreen mode Exit fullscreen mode

For enhanced security, I implement progressive delays that increase with each failed attempt:

// Progressive delay implementation for login attempts
async function handleLoginAttempt(username, password) {
  const user = await getUserByUsername(username);

  if (!user) {
    // Don't reveal that the user doesn't exist
    await simulatePasswordCheck();
    return { success: false, message: 'Invalid credentials' };
  }

  // Check for too many failed attempts
  if (user.failedAttempts >= 5 && user.lastFailedAttempt > Date.now() - 3600000) {
    return { success: false, message: 'Account temporarily locked. Try again later.' };
  }

  // Verify password
  const passwordValid = await bcrypt.compare(password, user.passwordHash);

  if (!passwordValid) {
    // Update failed attempts
    await updateUserFailedAttempts(user.id, {
      failedAttempts: user.failedAttempts + 1,
      lastFailedAttempt: Date.now()
    });

    // Progressive delay based on number of attempts
    const delaySeconds = Math.pow(2, user.failedAttempts);
    await new Promise(resolve => setTimeout(resolve, delaySeconds * 1000));

    return { success: false, message: 'Invalid credentials' };
  }

  // Reset failed attempts on successful login
  await updateUserFailedAttempts(user.id, {
    failedAttempts: 0,
    lastFailedAttempt: null
  });

  // Create and return session token
  const token = generateAuthToken(user.id, user.role);
  return { success: true, token };
}
Enter fullscreen mode Exit fullscreen mode

Continuous Authentication

Continuous authentication monitors user behavior throughout the session to detect anomalies that might indicate account compromise.

// Client-side continuous authentication monitoring
class ContinuousAuthMonitor {
  constructor() {
    this.baselineData = null;
    this.currentSessionData = {
      typingPatterns: [],
      mouseBehavior: [],
      interactionTimes: [],
      geolocations: []
    };
    this.riskScore = 0;
    this.riskThreshold = 70;

    this.setupListeners();
  }

  setupListeners() {
    // Monitor typing behavior
    document.addEventListener('keydown', this.captureTypingPattern.bind(this));

    // Monitor mouse behavior
    document.addEventListener('mousemove', this.captureMouseBehavior.bind(this));

    // Capture interaction times
    ['click', 'scroll', 'keydown'].forEach(event => {
      document.addEventListener(event, this.captureInteractionTime.bind(this));
    });

    // Periodically check geolocation if available
    if (navigator.geolocation) {
      this.geoInterval = setInterval(() => {
        navigator.geolocation.getCurrentPosition(
          this.captureGeolocation.bind(this)
        );
      }, 5 * 60 * 1000); // Every 5 minutes
    }

    // Periodically analyze the data
    this.analysisInterval = setInterval(this.analyzeData.bind(this), 60 * 1000);
  }

  captureTypingPattern(event) {
    // Record keystroke timing and patterns
    this.currentSessionData.typingPatterns.push({
      timestamp: Date.now(),
      keyCode: event.keyCode,
      timeBetweenKeys: this.lastKeyTime ? Date.now() - this.lastKeyTime : 0
    });

    this.lastKeyTime = Date.now();

    // Keep array size manageable
    if (this.currentSessionData.typingPatterns.length > 100) {
      this.currentSessionData.typingPatterns.shift();
    }
  }

  // Other capture methods...

  analyzeData() {
    if (!this.baselineData) {
      // First session, establish baseline
      this.baselineData = JSON.parse(JSON.stringify(this.currentSessionData));
      return;
    }

    // Calculate risk score based on deviations from baseline
    let typingRisk = this.analyzeTypingPatterns();
    let mouseRisk = this.analyzeMouseBehavior();
    let timeRisk = this.analyzeInteractionTimes();
    let geoRisk = this.analyzeGeolocations();

    // Weighted risk score
    this.riskScore = (typingRisk * 0.3) + (mouseRisk * 0.2) + 
                     (timeRisk * 0.2) + (geoRisk * 0.3);

    // If risk is above threshold, trigger re-authentication
    if (this.riskScore > this.riskThreshold) {
      this.triggerReAuthentication();
    }

    // Report telemetry to server
    this.reportTelemetry();
  }

  triggerReAuthentication() {
    // Prompt user to re-authenticate
    alert('For security purposes, please verify your identity');
    window.location.href = '/auth/verify?redirect=' + encodeURIComponent(window.location.href);
  }

  // Other analysis methods...
}

// Initialize the monitor
const authMonitor = new ContinuousAuthMonitor();
Enter fullscreen mode Exit fullscreen mode

On the server side, we can implement risk-based authentication that adapts security requirements based on the risk level:

// Server-side risk-based authentication
async function determineAuthenticationRequirements(user, loginContext) {
  // Calculate base risk score
  let riskScore = 0;

  // Check if IP is new for this user
  const isKnownIP = await isIPKnownForUser(user.id, loginContext.ipAddress);
  if (!isKnownIP) {
    riskScore += 30;
  }

  // Check if device is new
  const isKnownDevice = await isDeviceKnownForUser(user.id, loginContext.deviceId);
  if (!isKnownDevice) {
    riskScore += 25;
  }

  // Check if location is unusual
  const isUnusualLocation = await isLocationUnusualForUser(
    user.id, 
    loginContext.geoData.latitude,
    loginContext.geoData.longitude
  );
  if (isUnusualLocation) {
    riskScore += 40;
  }

  // Check if login time is unusual
  const isUnusualTime = await isLoginTimeUnusualForUser(user.id, new Date());
  if (isUnusualTime) {
    riskScore += 15;
  }

  // Determine authentication requirements based on risk score
  if (riskScore >= 70) {
    return {
      requireMFA: true,
      requireAdditionalVerification: true,
      sessionDuration: '15m',
      notifyUser: true
    };
  } else if (riskScore >= 40) {
    return {
      requireMFA: true,
      requireAdditionalVerification: false,
      sessionDuration: '1h',
      notifyUser: false
    };
  } else {
    return {
      requireMFA: user.mfaEnabled,
      requireAdditionalVerification: false,
      sessionDuration: '24h',
      notifyUser: false
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Biometric Authentication

Biometric authentication has become increasingly accessible as devices integrate fingerprint readers, facial recognition, and other biometric sensors.

// Using Web Authentication API for biometric authentication
async function authenticateWithBiometrics() {
  try {
    // Get challenge from server
    const response = await fetch('/auth/challenge');
    const { challenge, allowCredentials } = await response.json();

    // Prepare allowed credentials from user's registered authenticators
    const credentials = allowCredentials.map(cred => ({
      id: base64ToArrayBuffer(cred.id),
      type: 'public-key'
    }));

    // Request authentication
    const assertion = await navigator.credentials.get({
      publicKey: {
        challenge: base64ToArrayBuffer(challenge),
        rpId: window.location.hostname,
        allowCredentials: credentials,
        userVerification: 'required', // Force biometric verification
        timeout: 60000
      }
    });

    // Send assertion to server for verification
    const result = await fetch('/auth/verify-assertion', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: assertion.id,
        rawId: arrayBufferToBase64(assertion.rawId),
        response: {
          authenticatorData: arrayBufferToBase64(assertion.response.authenticatorData),
          clientDataJSON: arrayBufferToBase64(assertion.response.clientDataJSON),
          signature: arrayBufferToBase64(assertion.response.signature),
          userHandle: assertion.response.userHandle ? 
                      arrayBufferToBase64(assertion.response.userHandle) : null
        },
        type: assertion.type
      })
    });

    const { success, token } = await result.json();

    if (success) {
      // Store authentication token and redirect to app
      localStorage.setItem('auth_token', token);
      window.location.href = '/dashboard';
    } else {
      throw new Error('Authentication failed');
    }
  } catch (error) {
    console.error('Biometric authentication error:', error);
    // Fall back to alternative authentication method
    showPasswordLoginForm();
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementation Strategy and Considerations

From my experience, successful implementation of these authentication methods requires careful planning. I recommend a phased approach:

  1. Start with a risk assessment to identify your security requirements
  2. Implement basic security measures like password-based authentication with proper hashing
  3. Add MFA as a second layer of protection
  4. Gradually introduce more advanced methods like passwordless options
  5. Continuously monitor and improve your authentication system

Remember that security is a journey, not a destination. Regular security audits and staying updated with the latest vulnerabilities and mitigation techniques are essential.

Performance considerations are also important. Authentication should be secure but not create undue friction for users. Consider these optimization techniques:

  • Use client-side caching for non-sensitive session information
  • Implement session sharing across microservices
  • Use in-memory stores like Redis for session data
  • Consider edge caching for JWT verification keys

Finally, ensure your authentication system is accessible. Users with disabilities may struggle with certain authentication methods, so providing alternatives is crucial.

By combining these authentication methods and following best practices, you can create a robust security system that protects your users while providing a seamless experience. The key is finding the right balance between security and usability, which varies depending on your application's risk profile and user expectations.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)