Securing Serverless: A Deep Dive into AWS Lambda & Cognito
Serverless architecture promises infinite scalability and zero infrastructure management. But when you remove the server, you also remove the traditional session store. How do you secure an API that spins up and dies in milliseconds?
Enter the dynamic duo: AWS Cognito and AWS Lambda.
In this post, we'll dissect how these two services interact to provide robust, stateless authentication and authorization.
The Challenge: Stateless Auth
In a traditional monolith, you might have a session ID stored in a Redis cluster. The server checks the cookie, looks up the session, and knows who you are.
In serverless, functions are ephemeral. We can't rely on sticky sessions. We need a self-contained credential that travels with every request: the JSON Web Token (JWT).
AWS Cognito: The Bouncer
Cognito is often misunderstood because it does two very different things under similar names:
- User Pools: The "Directory". It handles sign-up, sign-in, password recovery, and MFA. It issues JWTs.
- Identity Pools (Federated Identities): The "Keymaster". It exchanges tokens (from User Pools, Google, Facebook) for temporary AWS IAM credentials to access resources like S3 or DynamoDB directly.
For most API-based applications, User Pools are the primary focus.
The Token Trio
When a user logs in to a User Pool, they receive three tokens:
- ID Token: Contains claims about the user's identity (email, phone_number, custom attributes).
- Access Token: Contains claims about what the user can do (scopes, groups). It’s used to authorize API calls.
- Refresh Token: Long-lived token used to get new ID and Access tokens without re-entering credentials.
AWS Lambda: The Logic
Lambda functions should focus on business logic, not parsing JWT headers and verifying cryptographic signatures. If every function had to validate tokens, you'd have massive code duplication and latency.
We need a gatekeeper.
The Integration: API Gateway Authorizers
The magic happens at the API Gateway layer, which sits in front of your Lambda functions.
1. Cognito User Pool Authorizer
This is the "no-code" solution. You configure API Gateway to trust a specific User Pool.
- Flow: Client sends request with
Authorization: Bearer <token>-> API Gateway validates signature & expiration against Cognito -> If valid, forwards to Lambda. - Pros: Zero code, managed by AWS, free.
- Cons: Limited flexibility. Can only validate that the token is valid, not complex logic like "user must be in group Admin AND have attribute subscription=active".
2. Lambda Authorizer (Custom)
For complex requirements, you write a dedicated Lambda function called an Authorizer.
- Flow:
- Client sends request.
- API Gateway pauses the request and calls your Authorizer Lambda.
- Authorizer validates token, checks database, or calls external services.
- Authorizer returns an IAM Policy document (Allow/Deny).
- API Gateway caches this policy (crucial for performance) and forwards the request to the backend Lambda.
// Simplified Authorizer Logic
exports.handler = async (event) => {
const token = event.authorizationToken;
try {
const decoded = verifyToken(token); // Verify JWT signature
if (decoded.group === 'admin') {
return generatePolicy('user', 'Allow', event.methodArn);
}
return generatePolicy('user', 'Deny', event.methodArn);
} catch (err) {
return generatePolicy('user', 'Deny', event.methodArn);
}
};Architecture Patterns
Choosing how your client talks to your backend is the most critical architectural decision you'll make. Here are the two dominant patterns.
The API Proxy Pattern
This is the most common pattern for REST and GraphQL APIs. API Gateway acts as the fortress wall, and Lambda is the guard inside.
The Flow:
- Auth: Client logs in and gets a JWT.
- Request: Client sends HTTP request to API Gateway with the JWT in the
Authorizationheader. - Gatekeeper: API Gateway (or a Lambda Authorizer) validates the token before the backend Lambda is even touched.
- Execution: If valid, the request is proxied to the backend Lambda.
Why choose this?
- Decoupling: Your client doesn't know (or care) that you use Lambda. It just sees a standard HTTP API.
- Throttling & Caching: API Gateway provides rate limiting and caching out of the box.
- Simplicity: It fits the standard mental model of "Client -> Server -> DB".
In this pattern, we bypass API Gateway entirely. The client speaks directly to the AWS Cloud API. This is often used for high-performance internal tools or when uploading large files directly to S3.
The Flow:
- Auth: Client logs in and gets an ID Token.
- Exchange: Client swaps this ID Token for temporary AWS IAM credentials (Access Key, Secret Key, Session Token) via Cognito Identity Pool.
- Direct Access: Client uses the AWS SDK (e.g., in the browser) to sign requests and invoke Lambda (or access S3/DynamoDB) directly.
Why choose this?
- Performance: You remove the API Gateway hop, saving a few milliseconds of latency.
- Cost: You don't pay for API Gateway request fees (which can be significant at scale).
- Direct AWS Access: Essential for patterns like "Signed URL Uploads" where the client uploads directly to S3, or for real-time apps using IoT Core.
The Trade-off: You are tightly coupling your client to AWS SDKs and infrastructure. You lose the "standard HTTP" interface.
Security Best Practices
- Validate Claims, Not Just Signatures: Don't just check if the token is signed by Cognito. Check the
aud(audience) claim to ensure it was issued for your app client. - Least Privilege: Your Lambda execution roles should have the bare minimum permissions.
- Use Scopes: In Cognito, define custom scopes (e.g.,
orders:read,orders:write) and enforce them in the Authorizer.
Conclusion
Cognito and Lambda provide a powerful, serverless-native way to handle identity. By offloading authentication to Cognito and authorization enforcement to API Gateway, your Lambda functions remain clean, focused, and secure.