DEV Community

Cover image for Secure Serverless Authentication: 5 Production-Ready Techniques for Developers
Aarav Joshi
Aarav Joshi

Posted on

Secure Serverless Authentication: 5 Production-Ready Techniques for Developers

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!

Authentication is one of the critical components of any modern application. With the growing popularity of serverless architectures, implementing authentication systems requires specialized approaches that work within the constraints of stateless, ephemeral computing environments. I've spent years building these systems and want to share the most effective techniques I've discovered.

Token-Based Authentication in Serverless Environments

Serverless functions are stateless by nature, making token-based authentication an ideal fit. JSON Web Tokens (JWT) have become the standard for this purpose due to their self-contained nature and cryptographic security.

When implementing JWT authentication in a serverless context, I focus on creating a system that validates user credentials and issues a signed token containing the user's identity and permissions.

const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');

exports.login = async (event) => {
  const { username, password } = JSON.parse(event.body);

  // Fetch user from database (implementation depends on your database)
  const user = await getUserFromDB(username);

  if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
    return {
      statusCode: 401,
      body: JSON.stringify({ message: 'Invalid credentials' })
    };
  }

  // Create token with appropriate claims and expiration
  const token = jwt.sign(
    { 
      sub: user.id,
      username: user.username,
      roles: user.roles 
    },
    process.env.JWT_SECRET,
    { expiresIn: '1h' }
  );

  // Set token as HttpOnly cookie for security
  return {
    statusCode: 200,
    headers: {
      'Set-Cookie': `token=${token}; HttpOnly; Secure; SameSite=Strict; Max-Age=3600; Path=/`,
    },
    body: JSON.stringify({ message: 'Login successful' })
  };
};
Enter fullscreen mode Exit fullscreen mode

For token validation, I create a dedicated middleware function that can be reused across multiple serverless functions:

const jwt = require('jsonwebtoken');

const verifyToken = async (event) => {
  try {
    // Extract token from cookies or Authorization header
    const token = extractTokenFromRequest(event);

    if (!token) {
      throw new Error('No token provided');
    }

    // Verify token and return user information
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    return { 
      isAuthorized: true, 
      user: decoded
    };
  } catch (error) {
    return { 
      isAuthorized: false, 
      error: error.message 
    };
  }
};

// Helper function to extract token from different sources
function extractTokenFromRequest(event) {
  // Check Authorization header
  const authHeader = event.headers?.Authorization;
  if (authHeader?.startsWith('Bearer ')) {
    return authHeader.substring(7);
  }

  // Check cookies
  if (event.cookies) {
    const cookies = event.cookies;
    const tokenCookie = cookies.find(c => c.startsWith('token='));
    if (tokenCookie) {
      return tokenCookie.split('=')[1];
    }
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

I've found that using short-lived tokens (1 hour or less) with refresh token rotation provides the best security. This approach minimizes the risk of token theft while maintaining a smooth user experience.

OAuth Integration for Serverless Functions

OAuth 2.0 enables third-party authentication without exposing user credentials. Implementing OAuth in a serverless environment requires careful handling of the authorization flow.

First, I create a function that initiates the OAuth flow:

const crypto = require('crypto');

exports.initiateOAuth = async (event) => {
  // Generate state parameter to prevent CSRF
  const state = crypto.randomBytes(16).toString('hex');

  // Store state in a database or cache with short TTL
  await storeStateInDatabase(state, 10 * 60); // 10 minutes

  // Construct the authorization URL
  const authUrl = `https://oauth-provider.com/authorize?` +
    `client_id=${process.env.CLIENT_ID}&` +
    `redirect_uri=${encodeURIComponent(process.env.REDIRECT_URI)}&` +
    `state=${state}&` +
    `response_type=code&` +
    `scope=profile email`;

  return {
    statusCode: 302,
    headers: {
      Location: authUrl
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

Then, I implement a callback handler to process the authorization code:

const axios = require('axios');

exports.oauthCallback = async (event) => {
  const { code, state } = event.queryStringParameters;

  // Verify state parameter to prevent CSRF
  const storedState = await getStateFromDatabase(state);
  if (!storedState) {
    return {
      statusCode: 400,
      body: JSON.stringify({ message: 'Invalid state parameter' })
    };
  }

  // Exchange code for tokens
  try {
    const tokenResponse = await axios.post('https://oauth-provider.com/token', {
      grant_type: 'authorization_code',
      code,
      redirect_uri: process.env.REDIRECT_URI,
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET
    });

    const { access_token, refresh_token } = tokenResponse.data;

    // Get user profile using access token
    const profileResponse = await axios.get('https://oauth-provider.com/userinfo', {
      headers: { Authorization: `Bearer ${access_token}` }
    });

    // Create or update user in your database
    const user = await createOrUpdateUser(profileResponse.data);

    // Create session token for the user
    const sessionToken = createSessionToken(user);

    return {
      statusCode: 302,
      headers: {
        'Set-Cookie': `token=${sessionToken}; HttpOnly; Secure; Path=/`,
        Location: '/dashboard'
      }
    };
  } catch (error) {
    console.error('OAuth token exchange failed:', error);
    return {
      statusCode: 500,
      body: JSON.stringify({ message: 'Authentication failed' })
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

A key consideration with serverless OAuth is cold start times. I prepackage dependencies and use connection pooling where possible to minimize latency during the authentication flow.

Multi-Factor Authentication for Added Security

Implementing MFA in serverless systems often means managing time-based one-time passwords (TOTP). The most secure approach stores only the secret key and validates codes within the serverless function.

Here's how I typically implement TOTP-based MFA:

const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

// Function to set up MFA for a user
exports.setupMFA = async (event) => {
  const userId = getUserIdFromEvent(event);

  // Generate a new secret
  const secret = speakeasy.generateSecret({
    name: `MyApp:${userId}`,
    length: 20
  });

  // Store the secret in your database
  await storeUserSecret(userId, secret.base32);

  // Generate QR code for easy setup
  const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);

  return {
    statusCode: 200,
    body: JSON.stringify({
      secret: secret.base32,
      qrCode: qrCodeUrl
    })
  };
};

// Function to verify TOTP code during login
exports.verifyMFA = async (event) => {
  const { userId, code } = JSON.parse(event.body);

  // Retrieve the user's secret from the database
  const userSecret = await getUserSecret(userId);

  // Verify the provided code
  const verified = speakeasy.totp.verify({
    secret: userSecret,
    encoding: 'base32',
    token: code,
    window: 1 // Allow 1 period before and after for clock skew
  });

  if (!verified) {
    return {
      statusCode: 401,
      body: JSON.stringify({ message: 'Invalid MFA code' })
    };
  }

  // Code is valid, proceed with authentication
  // Generate session token or complete the login process
  const token = generateSessionToken(userId);

  return {
    statusCode: 200,
    body: JSON.stringify({ token })
  };
};
Enter fullscreen mode Exit fullscreen mode

I've found that storing MFA secrets in a separate, highly secure database collection with additional encryption provides the best protection. Remember that MFA secrets are effectively password equivalents and should be treated with the same level of security.

Passwordless Authentication Systems

Passwordless authentication is gaining popularity for its security and usability benefits. In serverless environments, I implement this using short-lived tokens sent via email or SMS.

const crypto = require('crypto');
const AWS = require('aws-sdk');
const ses = new AWS.SES();

// Function to initiate passwordless login
exports.initiatePasswordlessLogin = async (event) => {
  const { email } = JSON.parse(event.body);

  // Generate a secure random token
  const token = crypto.randomBytes(32).toString('hex');

  // Set expiration time (15 minutes from now)
  const expiresAt = new Date();
  expiresAt.setMinutes(expiresAt.getMinutes() + 15);

  // Store token in database with expiration
  await storeLoginToken(email, token, expiresAt);

  // Create magic link
  const magicLink = `https://myapp.com/verify-login?token=${token}&email=${encodeURIComponent(email)}`;

  // Send email with magic link
  await ses.sendEmail({
    Source: 'noreply@myapp.com',
    Destination: { ToAddresses: [email] },
    Message: {
      Subject: { Data: 'Your login link' },
      Body: {
        Text: { Data: `Click this link to log in: ${magicLink}` },
        Html: { Data: `<p>Click <a href="${magicLink}">here</a> to log in</p>` }
      }
    }
  }).promise();

  return {
    statusCode: 200,
    body: JSON.stringify({ message: 'Login link sent' })
  };
};

// Function to verify magic link and complete login
exports.verifyLoginToken = async (event) => {
  const { token, email } = event.queryStringParameters;

  // Retrieve token from database
  const storedToken = await getLoginToken(email, token);

  // Check if token exists and is not expired
  if (!storedToken || new Date() > new Date(storedToken.expiresAt)) {
    return {
      statusCode: 401,
      body: JSON.stringify({ message: 'Invalid or expired token' })
    };
  }

  // Delete the used token
  await deleteLoginToken(email, token);

  // Create a session for the user
  const user = await getUserByEmail(email);
  const sessionToken = createSessionToken(user.id);

  return {
    statusCode: 302,
    headers: {
      'Set-Cookie': `token=${sessionToken}; HttpOnly; Secure; Path=/`,
      Location: '/dashboard'
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

The key security considerations I focus on with passwordless systems are token entropy (using cryptographically secure random values), short expiration times, and single-use tokens. These measures prevent token reuse or brute force attacks.

Rate Limiting to Prevent Abuse

Authentication endpoints are prime targets for brute force attacks. Implementing rate limiting is essential but presents challenges in serverless environments due to their stateless nature.

I use distributed rate limiting with DynamoDB for state management:

const AWS = require('aws-sdk');
const dynamoDB = new AWS.DynamoDB.DocumentClient();

// Middleware for rate limiting
exports.rateLimitMiddleware = async (event) => {
  const ip = event.requestContext.identity.sourceIp;
  const endpoint = event.path;
  const key = `${ip}:${endpoint}`;

  const now = Date.now();
  const windowSize = 60 * 1000; // 1 minute window
  const maxAttempts = 5; // Maximum attempts allowed in the window

  // Get current rate limiting status
  const params = {
    TableName: 'RateLimits',
    Key: { id: key }
  };

  try {
    const result = await dynamoDB.get(params).promise();
    const record = result.Item || { id: key, count: 0, firstRequest: now };

    // Check if we need to reset the window
    if (now - record.firstRequest > windowSize) {
      // Window expired, reset counter
      record.count = 1;
      record.firstRequest = now;
    } else {
      // Increment counter
      record.count += 1;
    }

    // Save updated record
    await dynamoDB.put({
      TableName: 'RateLimits',
      Item: record
    }).promise();

    // Check if rate limit exceeded
    if (record.count > maxAttempts) {
      return {
        statusCode: 429,
        body: JSON.stringify({ message: 'Too many requests, please try again later' })
      };
    }

    // Rate limit not exceeded, proceed with request
    return null;
  } catch (error) {
    console.error('Rate limiting error:', error);
    // On error, allow the request to proceed to avoid blocking legitimate users
    return null;
  }
};

// Example usage in an authentication function
exports.login = async (event) => {
  // Apply rate limiting
  const rateLimitResult = await exports.rateLimitMiddleware(event);
  if (rateLimitResult) {
    return rateLimitResult;
  }

  // Proceed with normal login logic
  // ...
};
Enter fullscreen mode Exit fullscreen mode

For high-volume applications, I've found Redis-based rate limiting to be more performant, though it requires maintaining a Redis instance outside the serverless environment.

Session Management for Serverless Applications

Session management in serverless applications requires careful consideration of state persistence and security. I typically use a combined approach of JWT for session identification and a database for session control.

const jwt = require('jsonwebtoken');
const AWS = require('aws-sdk');
const dynamoDB = new AWS.DynamoDB.DocumentClient();

// Create a new session
exports.createSession = async (userId) => {
  const sessionId = generateUniqueId();
  const now = Date.now();

  // Store session in database with metadata
  await dynamoDB.put({
    TableName: 'Sessions',
    Item: {
      id: sessionId,
      userId,
      createdAt: now,
      lastActivity: now,
      expiresAt: now + (7 * 24 * 60 * 60 * 1000), // 7 days
      userAgent: event.headers['User-Agent']
    }
  }).promise();

  // Create session token with minimal claims
  const token = jwt.sign(
    { sid: sessionId },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );

  return token;
};

// Verify and refresh session
exports.verifySession = async (event) => {
  try {
    // Extract and verify token
    const token = extractTokenFromRequest(event);
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Get session from database
    const result = await dynamoDB.get({
      TableName: 'Sessions',
      Key: { id: decoded.sid }
    }).promise();

    const session = result.Item;

    // Check if session exists and is not expired
    if (!session || Date.now() > session.expiresAt) {
      throw new Error('Session expired');
    }

    // Update last activity time
    await dynamoDB.update({
      TableName: 'Sessions',
      Key: { id: decoded.sid },
      UpdateExpression: 'set lastActivity = :now',
      ExpressionAttributeValues: {
        ':now': Date.now()
      }
    }).promise();

    // Return user information
    return {
      isAuthorized: true,
      userId: session.userId
    };
  } catch (error) {
    return {
      isAuthorized: false,
      error: error.message
    };
  }
};

// End session (logout)
exports.endSession = async (event) => {
  try {
    const token = extractTokenFromRequest(event);
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Delete session from database
    await dynamoDB.delete({
      TableName: 'Sessions',
      Key: { id: decoded.sid }
    }).promise();

    return {
      statusCode: 200,
      headers: {
        'Set-Cookie': 'token=; HttpOnly; Secure; SameSite=Strict; Max-Age=0; Path=/'
      },
      body: JSON.stringify({ message: 'Logged out successfully' })
    };
  } catch (error) {
    return {
      statusCode: 400,
      body: JSON.stringify({ message: 'Invalid session' })
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

This hybrid approach gives me the best of both worlds: the performance benefits of JWTs for session identification and the security benefits of server-side session validation and revocation.

I also implement a background function that runs periodically to clean up expired sessions:

exports.cleanupExpiredSessions = async (event) => {
  const now = Date.now();

  // Scan for expired sessions
  const scanResults = await dynamoDB.scan({
    TableName: 'Sessions',
    FilterExpression: 'expiresAt < :now',
    ExpressionAttributeValues: {
      ':now': now
    }
  }).promise();

  // Delete expired sessions
  const deletePromises = scanResults.Items.map(session => {
    return dynamoDB.delete({
      TableName: 'Sessions',
      Key: { id: session.id }
    }).promise();
  });

  await Promise.all(deletePromises);

  return {
    statusCode: 200,
    body: JSON.stringify({ 
      message: `Cleaned up ${deletePromises.length} expired sessions`
    })
  };
};
Enter fullscreen mode Exit fullscreen mode

Through years of implementing these authentication techniques in serverless environments, I've learned that security and performance can coexist. The key is to understand the unique constraints of serverless computing and design authentication systems that work with these constraints rather than against them.

By implementing token-based authentication with proper security measures, integrating OAuth for third-party authentication, adding multi-factor authentication for sensitive operations, offering passwordless options for better user experience, protecting against abuse with rate limiting, and managing sessions securely, I've built authentication systems that provide both security and scalability in serverless architectures.

The serverless paradigm continues to evolve, and so do authentication techniques. I'm constantly refining these approaches based on new security research and platform capabilities. The techniques shared here represent current best practices that have proven effective in production environments across various applications.


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)