Skip to content
All essays
ArchitectureMarch 22, 202514 min

Serverless Authentication: Build Secure Auth Systems

Master serverless authentication. Implement secure, scalable auth systems using AWS Lambda, Cognito, and JWT tokens.

Ü
Ümit Uz
Mobile & Full Stack Developer

Understanding Serverless Authentication

Authentication in serverless architectures requires a different approach than traditional monolithic applications. Instead of maintaining session state on servers, serverless auth leverages stateless JWT tokens, managed services, and distributed validation.

Traditional vs Serverless Auth

typescript
// Traditional: Session-based
// 1. User logs in
// 2. Server creates session in database
// 3. Server returns session cookie
// 4. Each request validates session against database
// Problem: Doesn't scale well with serverless

// Serverless: Token-based
// 1. User logs in
// 2. Auth service issues JWT token
// 3. Client stores token
// 4. Each request validates token signature
// Benefit: Stateless, scales infinitely

Authentication Architecture

Core Components

typescript
// Architecture Overview
┌─────────────┐
│   Client    │
└──────┬──────┘
       │
       │ 1. Login Request
       ▼
┌─────────────────────────┐
│  API Gateway / Cognito  │
└──────┬──────────────────┘
       │
       │ 2. Validate Credentials
       ▼
┌─────────────────────────┐
│   Lambda Authorizer     │ ◄───┐
└──────┬──────────────────┘     │
       │                        │
       │ 3. Issue/Validate JWT  │
       ▼                        │
┌─────────────────────────┐     │
│   Lambda Function       │─────┘
└─────────────────────────┘

AWS Cognito Integration

User Pool Setup

typescript
import {
  CognitoIdentityProviderClient,
  AdminCreateUserCommand,
  AdminInitiateAuthCommand,
  AdminRespondToAuthChallengeCommand,
} from '@aws-sdk/client-cognito-identity-provider';

const client = new CognitoIdentityProviderClient({
  region: process.env.AWS_REGION,
});

const USER_POOL_ID = process.env.USER_POOL_ID;
const CLIENT_ID = process.env.COGNITO_CLIENT_ID;

// Register new user
export const handler = async (event: any) => {
  const { email, password, name } = JSON.parse(event.body);

  const command = new AdminCreateUserCommand({
    UserPoolId: USER_POOL_ID,
    Username: email,
    TemporaryPassword: password,
    MessageAction: 'SUPPRESS', // Don't send welcome email
    UserAttributes: [
      {
        Name: 'email',
        Value: email,
      },
      {
        Name: 'name',
        Value: name,
      },
      {
        Name: 'email_verified',
        Value: 'true',
      },
    ],
  });

  try {
    const result = await client.send(command);

    // Permanent password
    await setPassword(email, password);

    return {
      statusCode: 201,
      body: JSON.stringify({
        userId: result.User?.Attributes?.find((attr) => attr.Name === 'sub')?.Value,
        message: 'User created successfully',
      }),
    };
  } catch (error) {
    return {
      statusCode: 400,
      body: JSON.stringify({ error: error.message }),
    };
  }
};

async function setPassword(username: string, password: string) {
  const authCommand = new AdminInitiateAuthCommand({
    UserPoolId: USER_POOL_ID,
    ClientId: CLIENT_ID,
    AuthFlow: 'ADMIN_NO_SRP_AUTH',
    AuthParameters: {
      USERNAME: username,
      PASSWORD: password,
    },
  });

  const authResult = await client.send(authCommand);

  // Complete password challenge
  if (authResult.ChallengeName === 'NEW_PASSWORD_REQUIRED') {
    await client.send(new AdminRespondToAuthChallengeCommand({
      UserPoolId: USER_POOL_ID,
      ClientId: CLIENT_ID,
      ChallengeName: 'NEW_PASSWORD_REQUIRED',
      Session: authResult.Session,
      ChallengeResponses: {
        USERNAME: username,
        NEW_PASSWORD: password,
      },
    }));
  }
}

Login Handler

typescript
export const loginHandler = async (event: any) => {
  const { email, password } = JSON.parse(event.body);

  const command = new AdminInitiateAuthCommand({
    UserPoolId: USER_POOL_ID,
    ClientId: CLIENT_ID,
    AuthFlow: 'ADMIN_NO_SRP_AUTH',
    AuthParameters: {
      USERNAME: email,
      PASSWORD: password,
    },
  });

  try {
    const result = await client.send(command);

    // Handle MFA challenge if enabled
    if (result.ChallengeName === 'SOFTWARE_TOKEN_MFA') {
      return {
        statusCode: 200,
        body: JSON.stringify({
          requiresMFA: true,
          session: result.Session,
        }),
      };
    }

    return {
      statusCode: 200,
      body: JSON.stringify({
        accessToken: result.AuthenticationResult?.AccessToken,
        idToken: result.AuthenticationResult?.IdToken,
        refreshToken: result.AuthenticationResult?.RefreshToken,
        expiresIn: result.AuthenticationResult?.ExpiresIn,
      }),
    };
  } catch (error) {
    return {
      statusCode: 401,
      body: JSON.stringify({ error: 'Invalid credentials' }),
    };
  }
};

Lambda Authorizers

Custom Authorizer

typescript
import {
  APIGatewayTokenAuthorizerEvent,
  APIGatewayAuthorizerResult,
  PolicyDocument,
  Statement,
} from 'aws-lambda';

import { verify } from 'jsonwebtoken';
import { JwtPayload } from 'jsonwebtoken';

// Cognito JWKS URL
const JWKS_URL = `https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${process.env.USER_POOL_ID}/.well-known/jwks.json`;

interface AuthorizerContext {
  userId: string;
  email: string;
}

export const authorizerHandler = async (
  event: APIGatewayTokenAuthorizerEvent
): Promise<APIGatewayAuthorizerResult> => {
  try {
    const token = event.authorizationToken?.replace('Bearer ', '');

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

    // Verify JWT token
    const decoded = await verifyToken(token);

    // Generate IAM policy
    const policy = generatePolicy(
      decoded.sub,
      'Allow',
      event.methodArn,
      {
        userId: decoded.sub,
        email: decoded.email,
      }
    );

    return policy;
  } catch (error) {
    console.error('Authorization failed:', error);

    return generatePolicy('user', 'Deny', event.methodArn);
  }
};

async function verifyToken(token: string): Promise<JwtPayload> {
  // Fetch JWKS
  const response = await fetch(JWKS_URL);
  const jwks = await response.json();

  // Get key ID from token header
  const decodedToken = verify(token, getSecretKey(jwks, token), {
    issuer: `https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${process.env.USER_POOL_ID}`,
    audience: process.env.CLIENT_ID,
  });

  return decodedToken as JwtPayload;
}

function generatePolicy(
  principalId: string,
  effect: 'Allow' | 'Deny',
  resource: string,
  context?: AuthorizerContext
): APIGatewayAuthorizerResult {
  const authResponse: APIGatewayAuthorizerResult = {
    principalId,
    policyDocument: {
      Version: '2012-10-17',
      Statement: [
        {
          Action: 'execute-api:Invoke',
          Effect: effect,
          Resource: resource,
        },
      ],
    },
  };

  if (context) {
    authResponse.context = context;
  }

  return authResponse;
}

Using Authorizer in Serverless Framework

yaml
# serverless.yml
service: auth-service

provider:
  name: aws
  runtime: nodejs18.x
  environment:
    USER_POOL_ID: !Ref CognitoUserPool
    CLIENT_ID: !Ref CognitoUserPoolClient

functions:
  authorizer:
    handler: authorizer.handler
    name: token-authorizer

  getUser:
    handler: getUser.handler
    events:
      - http:
          path: /users/{id}
          method: get
          authorizer:
            name: authorizer
            type: TOKEN
            identitySource: method.request.header.Authorization

resources:
  Resources:
    CognitoUserPool:
      Type: AWS::Cognito::UserPool
      Properties:
        UserPoolName: my-auth-pool
        UsernameAttributes:
          - email
        AutoVerifiedAttributes:
          - email
        Policies:
          PasswordPolicy:
            MinimumLength: 8
            RequireLowercase: true
            RequireUppercase: true
            RequireNumbers: true

    CognitoUserPoolClient:
      Type: AWS::Cognito::UserPoolClient
      Properties:
        UserPoolId: !Ref CognitoUserPool
        ExplicitAuthFlows:
          - ADMIN_NO_SRP_AUTH
        GenerateSecret: false

JWT Token Management

Token Generation and Refresh

typescript
import { sign } from 'jsonwebtoken';

interface TokenPayload {
  userId: string;
  email: string;
  roles: string[];
}

export const tokenHandler = async (event: any) => {
  const { refreshToken } = JSON.parse(event.body);

  try {
    // Verify refresh token with Cognito
    const command = new AdminInitiateAuthCommand({
      UserPoolId: USER_POOL_ID,
      ClientId: CLIENT_ID,
      AuthFlow: 'REFRESH_TOKEN_AUTH',
      AuthParameters: {
        REFRESH_TOKEN: refreshToken,
      },
    });

    const result = await client.send(command);

    return {
      statusCode: 200,
      body: JSON.stringify({
        accessToken: result.AuthenticationResult?.AccessToken,
        idToken: result.AuthenticationResult?.IdToken,
        expiresIn: result.AuthenticationResult?.ExpiresIn,
      }),
    };
  } catch (error) {
    return {
      statusCode: 401,
      body: JSON.stringify({ error: 'Invalid refresh token' }),
    };
  }
};

// Custom JWT generation (for microservices)
function generateAccessToken(payload: TokenPayload): string {
  return sign(payload, process.env.JWT_SECRET, {
    expiresIn: '15m',
    issuer: 'my-auth-service',
    audience: 'my-api',
  });
}

function generateRefreshToken(payload: TokenPayload): string {
  return sign(payload, process.env.JWT_SECRET, {
    expiresIn: '7d',
    issuer: 'my-auth-service',
  });
}

Token Validation Middleware

typescript
import { Request, Response, NextFunction } from 'express';
import { verify } from 'jsonwebtoken';

interface AuthenticatedRequest extends Request {
  user?: TokenPayload;
}

export const authenticate = async (
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
) => {
  try {
    const token = req.headers.authorization?.replace('Bearer ', '');

    if (!token) {
      return res.status(401).json({ error: 'No token provided' });
    }

    const decoded = verify(token, process.env.JWT_SECRET!) as TokenPayload;

    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
};

export const authorize = (...roles: string[]) => {
  return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Unauthorized' });
    }

    const hasRole = roles.some((role) => req.user!.roles.includes(role));

    if (!hasRole) {
      return res.status(403).json({ error: 'Forbidden' });
    }

    next();
  };
};

Role-Based Access Control

Cognito Groups

typescript
import {
  AdminAddUserToGroupCommand,
  CreateGroupCommand,
} from '@aws-sdk/client-cognito-identity-provider';

async function setupUserGroups() {
  // Create groups
  const groups = ['Admin', 'User', 'Moderator'];

  for (const group of groups) {
    await client.send(new CreateGroupCommand({
      UserPoolId: USER_POOL_ID,
      GroupName: group,
    }));
  }
}

async function addUserToGroup(userId: string, group: string) {
  await client.send(new AdminAddUserToGroupCommand({
    UserPoolId: USER_POOL_ID,
    Username: userId,
    GroupName: group,
  }));
}

// In authorizer, check group membership
async function getUserGroups(userId: string): Promise<string[]> {
  const command = new AdminListGroupsForUserCommand({
    UserPoolId: USER_POOL_ID,
    Username: userId,
  });

  const result = await client.send(command);
  return result.Groups?.map((group) => group.GroupName || '') || [];
}

Fine-Grained Permissions

typescript
interface Permission {
  resource: string;
  actions: string[];
}

const rolePermissions: Record<string, Permission[]> = {
  Admin: [
    { resource: '*', actions: ['*'] },
  ],
  User: [
    { resource: 'posts', actions: ['read', 'create'] },
    { resource: 'comments', actions: ['read', 'create'] },
  ],
  Moderator: [
    { resource: 'posts', actions: ['read', 'update', 'delete'] },
    { resource: 'comments', actions: ['read', 'delete'] },
  ],
};

function checkPermission(
  role: string,
  resource: string,
  action: string
): boolean {
  const permissions = rolePermissions[role] || [];

  return permissions.some((permission) => {
    const matchesResource = permission.resource === '*' ||
                           permission.resource === resource;

    const matchesAction = permission.actions.includes('*') ||
                         permission.actions.includes(action);

    return matchesResource && matchesAction;
  });
}

Multi-Factor Authentication (MFA)

TOTP Setup

typescript
import {
  AssociateSoftwareTokenCommand,
  VerifySoftwareTokenCommand,
  SetUserMFAPreferenceCommand,
} from '@aws-sdk/client-cognito-identity-provider';

export const setupMFA = async (event: any) => {
  const { userId } = event.requestContext.authorizer;

  // Associate software token
  const associateCommand = new AssociateSoftwareTokenCommand({
    UserPoolId: USER_POOL_ID,
    Username: userId,
  });

  const result = await client.send(associateCommand);

  return {
    statusCode: 200,
    body: JSON.stringify({
      secretCode: result.SecretCode,
    }),
  };
};

export const verifyMFA = async (event: any) => {
  const { userId } = event.requestContext.authorizer;
  const { code, session } = JSON.parse(event.body);

  const verifyCommand = new VerifySoftwareTokenCommand({
    UserPoolId: USER_POOL_ID,
    Username: userId,
    Session: session,
    UserCode: code,
  });

  const result = await client.send(verifyCommand);

  // Enable MFA
  await client.send(new SetUserMFAPreferenceCommand({
    UserPoolId: USER_POOL_ID,
    Username: userId,
    SoftwareTokenMfaSettings: {
      Enabled: true,
      PreferredMfa: true,
    },
  }));

  return {
    statusCode: 200,
    body: JSON.stringify({
      message: 'MFA enabled successfully',
    }),
  };
};

Social Login Integration

Google OAuth

typescript
import axios from 'axios';

export const googleAuthHandler = async (event: any) => {
  const { code } = JSON.parse(event.body);

  // Exchange code for tokens
  const tokenResponse = await axios.post(
    'https://oauth2.googleapis.com/token',
    {
      code,
      client_id: process.env.GOOGLE_CLIENT_ID,
      client_secret: process.env.GOOGLE_CLIENT_SECRET,
      redirect_uri: process.env.GOOGLE_REDIRECT_URI,
      grant_type: 'authorization_code',
    }
  );

  // Get user info
  const userInfoResponse = await axios.get(
    'https://www.googleapis.com/oauth2/v2/userinfo',
    {
      headers: {
        Authorization: `Bearer ${tokenResponse.data.access_token}`,
      },
    }
  );

  const userInfo = userInfoResponse.data;

  // Find or create user in Cognito
  const userId = await findOrCreateUser(userInfo);

  // Return tokens
  return {
    statusCode: 200,
    body: JSON.stringify({
      userId,
      email: userInfo.email,
      name: userInfo.name,
    }),
  };
};

async function findOrCreateUser(userInfo: any): Promise<string> {
  // Check if user exists
  // If not, create with Google info
  // Return user ID
}

Security Best Practices

Token Storage

typescript
// Frontend: Store tokens securely
// ✓ Use httpOnly cookies
// ✓ Use secure flag (HTTPS only)
// ✓ Use sameSite flag

// ❌ Don't store in localStorage (XSS vulnerable)
// ✓ Use httpOnly cookies

// Set cookie on login
response.setHeader('Set-Cookie', [
  `token=${token}; HttpOnly; Secure; SameSite=Strict; Max-Age=900`, // 15 min
  `refreshToken=${refreshToken}; HttpOnly; Secure; SameSite=Strict; Max-Age=604800`, // 7 days
]);

Rate Limiting

typescript
// Prevent brute force attacks
import RateLimit from 'express-rate-limit';

const loginLimiter = RateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts
  message: 'Too many login attempts',
});

app.use('/auth/login', loginLimiter);

Monitoring and Logging

CloudWatch Metrics

typescript
import { CloudWatch } from '@aws-sdk/client-cloudwatch';

const cloudwatch = new CloudWatch({ region: process.env.AWS_REGION });

async function trackLogin(success: boolean, userId: string) {
  await cloudwatch.putMetricData({
    Namespace: 'Authentication',
    MetricData: [{
      MetricName: 'LoginAttempts',
      Dimensions: [{
        Name: 'Success',
        Value: success.toString(),
      }],
      Value: 1,
      Unit: 'Count',
    }],
  });
}

Testing Authentication

Unit Tests

typescript
import { verify } from 'jsonwebtoken';

describe('Authentication', () => {
  it('should validate JWT token', () => {
    const token = generateToken({ userId: '123', email: 'test@example.com' });

    const decoded = verify(token, process.env.JWT_SECRET);

    expect(decoded.userId).toBe('123');
    expect(decoded.email).toBe('test@example.com');
  });

  it('should reject invalid token', () => {
    const invalidToken = 'invalid.token.here';

    expect(() => {
      verify(invalidToken, process.env.JWT_SECRET);
    }).toThrow();
  });
});

Conclusion

Serverless authentication leverages managed services and stateless tokens to create secure, scalable auth systems. By using AWS Cognito, Lambda authorizers, and JWT tokens, you can build enterprise-grade authentication without managing servers.

Key benefits:

  • Scalable without managing infrastructure
  • Built-in security best practices
  • Support for multiple authentication methods
  • Fine-grained access control
  • Easy integration with other AWS services

Build secure serverless authentication systems today!

Enjoyed this essay? Share it.

Related essays

Next essay
Serverless Cost Optimization: Reduce Your Cloud Bills