
How we wrapped AWS Cognito into a reusable middleware layer — token extraction, JWT validation, permission checks with AND/OR logic, user context enrichment, and circuit breaker protection for Cognito API calls.
Every authenticated API endpoint at TCTF follows the same pattern: extract the JWT from the Authorization header, validate it against AWS Cognito, check the user's permissions, enrich the request with user context, and then — only then — run the business logic. Without a shared middleware layer, every Lambda function would implement this pattern independently. 34 services, hundreds of Lambda functions, each with its own token parsing, its own validation logic, its own permission checks. One inconsistency and you have a security hole. This article covers the Cognito middleware we built in tctf-utils — a composable authentication pipeline that handles the entire auth flow in a single, reusable layer.
Authentication is the most security-critical code in any platform. A bug in a profile page shows wrong data. A bug in authentication gives unauthorized access to every feature behind it.
In a monolithic application, authentication middleware runs once — in the framework's middleware pipeline. Express has app.use(authMiddleware). Django has MIDDLEWARE settings. The auth logic is written once and applied to every route.
In serverless, there is no shared middleware pipeline. Each Lambda function is an independent entry point. Each function must authenticate the request independently. If you copy-paste the auth logic into every function, you have 200 copies of security-critical code. When you find a bug, you fix it in 200 places. When you miss one, you have a vulnerability.
The Cognito middleware in tctf-utils solves this by providing a composable pipeline that any Lambda function can use. Import it, configure it, and the auth flow is handled. The function's code starts after authentication — with a fully validated, permission-checked, context-enriched request.
🔐Authentication is security-critical code. In serverless, every Lambda function is an independent entry point. The middleware ensures every function authenticates consistently — one implementation, 34 services.
The authentication pipeline has five steps, each building on the previous one.
Step 1: Token Extraction. The middleware reads the Authorization header from the API Gateway event. It expects a Bearer token — the string after Bearer is the JWT. If the header is missing or malformed, the pipeline stops and returns a 401 with a MissingTokenError.
Step 2: JWT Validation. The extracted token is validated against AWS Cognito. The middleware checks the signature (is this token from our Cognito User Pool?), the expiration (has the token expired?), the issuer (does the iss claim match our User Pool URL?), and the audience (does the aud claim match our app client ID?). If any check fails, the pipeline returns a 401 with a TokenValidationError.
Step 3: Permission Check. The middleware extracts the user's groups from the JWT claims (cognito:groups) and checks them against the required permissions for the endpoint. Permissions can use AND logic (user must have all listed permissions) or OR logic (user must have any listed permission). If the check fails, the pipeline returns a 403 with an AuthorizationError.
Step 4: User Context Enrichment. The middleware constructs a UserContext object from the JWT claims: userId (from the sub claim), email, groups, userGroup (primary group), subRole, and permissions. This object is passed to the Lambda handler so it does not need to parse the JWT itself.
Step 5: Handler Execution. The Lambda handler runs with the authenticated, authorized, context-enriched request. It can access the user's identity, groups, and permissions without any auth code.
🔄Five steps: extract token → validate JWT → check permissions → enrich context → run handler. Each step can fail independently with a specific error. The handler only runs if all steps pass.
The permission system supports flexible access control patterns.
The simplest case: require a single permission. requirePermissions({ required: 'users:read' }) checks that the user has the users:read permission. If they do not, the request is denied.
OR logic: requirePermissions({ required: ['users:read', 'users:write'] }) checks that the user has either users:read or users:write. Any one permission is sufficient. This is the default behavior.
AND logic: requirePermissions({ required: ['users:read', 'users:write'], requireAll: true }) checks that the user has both permissions. Both are required.
The checkPermissions function handles the actual check. It extracts the user context from the event, resolves the user's permissions from their groups, and evaluates the required permissions against the user's actual permissions.
The permission model is role-based. Users belong to Cognito groups (admin, moderator, member). Each group maps to a set of permissions defined in the role-permissions module. An admin has users:read, users:write, users:moderate, content:moderate. A member has users:read, content:read. The middleware resolves the group to permissions and checks against the required set.
The skipCheck option bypasses permission checking entirely — useful for testing and for endpoints that are authenticated but not authorized (any logged-in user can access them).
After the middleware pipeline completes, the Lambda handler receives a UserContext object with everything it needs to know about the authenticated user.
userId is the Cognito sub claim — a UUID that uniquely identifies the user across the platform. This is the primary key used in DynamoDB for user-related data.
email is the user's email address from the JWT claims. It is available for logging, notifications, and display purposes.
groups is an array of Cognito group names the user belongs to — admin, moderator, member, etc. A user can belong to multiple groups.
userGroup is the primary group — the first group in the array, used for quick role checks without iterating the full list.
subRole is an optional sub-role within the primary group — for example, a moderator might have a sub-role of content-moderator or user-moderator.
permissions is the resolved permission set — the union of all permissions from all groups the user belongs to. This is what the permission check evaluates against.
The extractUserContext and getCurrentUser functions provide two ways to access this context. extractUserContext parses the JWT claims from the API Gateway event. getCurrentUser is a convenience wrapper that returns the same UserContext object.
The middleware makes API calls to AWS Cognito — to validate tokens, to look up user details, to check group membership. These calls can fail. Cognito has rate limits. Cognito has outages. Network issues can cause timeouts.
The Cognito middleware integrates with the circuit breaker pattern from tctf-utils. Every Cognito API call goes through a circuit breaker. If Cognito fails repeatedly, the circuit opens and subsequent auth attempts fail fast with a clear error instead of hanging on timeouts.
The circuit breaker configuration is specific to Cognito: a dedicated service key (COGNITO_AUTH), configurable failure threshold and reset timeout, and storage in DynamoDB so the circuit state persists across Lambda invocations.
The handleCognitoError function classifies Cognito errors. NotAuthorizedException becomes an AuthenticationError. UserNotFoundException becomes a UserNotFoundError. Throttling exceptions are retryable. Service unavailable errors trip the circuit breaker. Each error type gets the appropriate handling.
This means a Cognito outage does not cascade through the platform. The circuit breaker opens, auth requests fail fast, and the platform can degrade gracefully — showing cached content, allowing read-only access, or displaying a maintenance message — instead of hanging on every request.
🛡️ Cognito API calls are circuit-breaker protected. A Cognito outage does not cascade — the circuit opens, auth fails fast, and the platform degrades gracefully instead of hanging.
Using the middleware is straightforward. A Lambda handler imports withErrorHandling and the permission utilities, wraps the handler function, and declares the required permissions.
The withErrorHandling wrapper (from the error handling module) provides the try-catch, correlation ID, and timing. Inside the handler, getCurrentUser extracts the authenticated user context. If the endpoint needs specific permissions, requirePermissions is called before the business logic.
For endpoints that need authentication but not specific permissions — like a user viewing their own profile — the handler just calls getCurrentUser. The JWT is validated by the API Gateway authorizer, and the middleware extracts the user context.
For admin endpoints that need specific permissions — like managing other users — the handler calls requirePermissions with the required permission set. The middleware checks the user's groups against the required permissions and throws AuthorizationError if the check fails.
The pattern is consistent across all 34 services. Every authenticated endpoint uses the same middleware. Every permission check uses the same RBAC model. Every error is handled by the same error handling architecture. The security posture is uniform because the implementation is shared.
Building the auth middleware taught us several lessons.
First, never parse JWTs in business logic. The middleware handles all JWT parsing, validation, and claim extraction. The handler receives a typed UserContext object. If a handler is importing jsonwebtoken, something is wrong.
Second, permission checks should be declarative, not imperative. requirePermissions({ required: ['users:write'], requireAll: true }) reads like a policy declaration. An if-else chain checking group names reads like spaghetti. Declarative permissions are easier to audit, easier to test, and harder to get wrong.
Third, circuit breaker protection for the auth provider is not optional. Cognito is a managed service, but managed services have outages. Without a circuit breaker, a Cognito outage means every request in the platform hangs until timeout. With a circuit breaker, the outage is detected in seconds and the platform degrades gracefully.
Fourth, the UserContext should be the only way handlers access user identity. No parsing headers. No reading JWT claims. No calling Cognito directly. One object, one source of truth, one place to add new user attributes when the auth model evolves.
📋Key lessons: never parse JWTs in business logic, make permission checks declarative, protect the auth provider with a circuit breaker, and use UserContext as the single source of user identity.
The Cognito middleware is the security boundary of the platform. Every request passes through it. Every user identity is validated by it. Every permission is checked by it. And because it is a shared library in tctf-utils, every service gets the same security guarantees without writing a single line of auth code. That is the point of middleware — handle the cross-cutting concern once, correctly, and let the business logic focus on what it does best.
Never miss an edition
Subscribe to get TCTF newsletters delivered to your inbox.