AWS Cognito Authentication: User Pools, JWT Verification, and Social Federation
Implement AWS Cognito authentication in your app. Covers User Pool setup with Terraform, JWT verification in Node.js, hosted UI, Google and GitHub OAuth federation, custom attributes, and MFA.
AWS Cognito is one of the most misunderstood services in AWS. The documentation is dense, the configuration is byzantine, and the error messages are cryptic. But the underlying proposition is compelling: managed user identity with MFA, social login, enterprise SSO, JWT issuance, and PKCE flows — without running auth infrastructure.
This guide cuts through the noise with Terraform setup, correct JWT verification, social provider federation, and the gotchas that burn teams.
Infrastructure: Terraform
# terraform/cognito.tf
resource "aws_cognito_user_pool" "app" {
name = "${var.app_name}-${var.environment}"
# Password policy
password_policy {
minimum_length = 12
require_lowercase = true
require_uppercase = true
require_numbers = true
require_symbols = false
temporary_password_validity_days = 7
}
# Username configuration
username_configuration {
case_sensitive = false
}
# Login with email address
alias_attributes = ["email"]
auto_verified_attributes = ["email"]
# Email verification
verification_message_template {
default_email_option = "CONFIRM_WITH_CODE"
email_subject = "Your verification code"
email_message = "Your verification code is {####}"
}
# Email configuration (use SES in production)
email_configuration {
email_sending_account = "DEVELOPER"
source_arn = aws_ses_email_identity.app.arn
from_email_address = "noreply@${var.domain}"
}
# Account recovery
account_recovery_setting {
recovery_mechanism {
name = "verified_email"
priority = 1
}
}
# MFA — optional (users can enable TOTP)
mfa_configuration = "OPTIONAL"
software_token_mfa_configuration {
enabled = true
}
# Schema: custom attributes
schema {
name = "plan"
attribute_data_type = "String"
mutable = true
required = false
string_attribute_constraints {
min_length = 0
max_length = 50
}
}
schema {
name = "organization_id"
attribute_data_type = "String"
mutable = true
required = false
string_attribute_constraints {
min_length = 0
max_length = 50
}
}
# Lambda triggers
lambda_config {
pre_sign_up = aws_lambda_function.cognito_pre_signup.arn
post_confirmation = aws_lambda_function.cognito_post_confirmation.arn
pre_token_generation = aws_lambda_function.cognito_pre_token.arn
}
tags = var.common_tags
}
# App client — for server-side auth (client credentials or authorization code)
resource "aws_cognito_user_pool_client" "web" {
name = "${var.app_name}-web"
user_pool_id = aws_cognito_user_pool.app.id
generate_secret = false # false for SPAs/mobile; true for server-to-server
# Token validity
access_token_validity = 60 # minutes
id_token_validity = 60 # minutes
refresh_token_validity = 30 # days
token_validity_units {
access_token = "minutes"
id_token = "minutes"
refresh_token = "days"
}
# Allowed auth flows
explicit_auth_flows = [
"ALLOW_USER_SRP_AUTH", # Secure Remote Password (recommended)
"ALLOW_REFRESH_TOKEN_AUTH",
"ALLOW_USER_PASSWORD_AUTH", # For server-side; remove if not needed
]
# OAuth settings for hosted UI
allowed_oauth_flows_user_pool_client = true
allowed_oauth_flows = ["code"]
allowed_oauth_scopes = ["email", "openid", "profile"]
callback_urls = [
"https://${var.domain}/auth/callback",
"http://localhost:3000/auth/callback",
]
logout_urls = [
"https://${var.domain}",
"http://localhost:3000",
]
# Prevent user existence errors from leaking info
prevent_user_existence_errors = "ENABLED"
# Read/write attributes
read_attributes = ["email", "name", "custom:plan", "custom:organization_id"]
write_attributes = ["email", "name"]
# Social providers
supported_identity_providers = ["COGNITO", "Google", "GitHub"]
}
# Hosted UI domain
resource "aws_cognito_user_pool_domain" "app" {
domain = "${var.app_name}-${var.environment}"
user_pool_id = aws_cognito_user_pool.app.id
# For custom domain: set domain + certificate_arn
}
# Google identity provider
resource "aws_cognito_identity_provider" "google" {
user_pool_id = aws_cognito_user_pool.app.id
provider_name = "Google"
provider_type = "Google"
provider_details = {
client_id = var.google_client_id
client_secret = var.google_client_secret
authorize_scopes = "email profile openid"
}
attribute_mapping = {
email = "email"
name = "name"
username = "sub"
picture = "picture"
}
}
output "user_pool_id" { value = aws_cognito_user_pool.app.id }
output "client_id" { value = aws_cognito_user_pool_client.web.id }
output "hosted_ui_domain" { value = "https://${aws_cognito_user_pool_domain.app.domain}.auth.${var.aws_region}.amazoncognito.com" }
JWT Verification in Node.js
This is the most critical part — and the most commonly broken. You must verify the JWT signature, expiry, audience, and issuer.
// lib/auth/cognito-verifier.ts
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
const REGION = process.env.AWS_REGION!;
const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID!;
const CLIENT_ID = process.env.COGNITO_CLIENT_ID!;
const ISSUER = `https://cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}`;
const JWKS_URI = `${ISSUER}/.well-known/jwks.json`;
// Cache JWKS remotely (jose handles caching automatically)
const JWKS = createRemoteJWKSet(new URL(JWKS_URI));
export interface CognitoUser {
sub: string;
email: string;
name?: string;
emailVerified: boolean;
groups: string[];
plan?: string;
organizationId?: string;
tokenUse: "access" | "id";
}
export async function verifyAccessToken(token: string): Promise<CognitoUser> {
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: CLIENT_ID,
});
validateTokenUse(payload, "access");
return extractUser(payload);
}
export async function verifyIdToken(token: string): Promise<CognitoUser> {
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: CLIENT_ID,
});
validateTokenUse(payload, "id");
return extractUser(payload);
}
function validateTokenUse(
payload: JWTPayload,
expected: "access" | "id"
): void {
const tokenUse = payload["token_use"] as string;
if (tokenUse !== expected) {
throw new Error(
`Invalid token_use: expected "${expected}", got "${tokenUse}"`
);
}
}
function extractUser(payload: JWTPayload): CognitoUser {
return {
sub: payload.sub!,
email: (payload["email"] as string) ?? "",
name: payload["name"] as string | undefined,
emailVerified: Boolean(payload["email_verified"]),
groups: (payload["cognito:groups"] as string[]) ?? [],
plan: payload["custom:plan"] as string | undefined,
organizationId: payload["custom:organization_id"] as string | undefined,
tokenUse: payload["token_use"] as "access" | "id",
};
}
☁️ Is Your Cloud Costing Too Much?
Most teams overspend 30–40% on cloud — wrong instance types, no reserved pricing, bloated storage. We audit, right-size, and automate your infrastructure.
- AWS, GCP, Azure certified engineers
- Infrastructure as Code (Terraform, CDK)
- Docker, Kubernetes, GitHub Actions CI/CD
- Typical audit recovers $500–$3,000/month in savings
Express / Fastify Auth Middleware
// middleware/cognito-auth.ts (Fastify)
import type { FastifyRequest, FastifyReply } from "fastify";
import { verifyAccessToken, type CognitoUser } from "@/lib/auth/cognito-verifier";
declare module "fastify" {
interface FastifyRequest {
cognitoUser?: CognitoUser;
}
}
export async function cognitoAuthMiddleware(
req: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
reply.status(401).send({ error: "Missing authorization header" });
return;
}
const token = authHeader.slice(7);
try {
req.cognitoUser = await verifyAccessToken(token);
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid token";
reply.status(401).send({ error: message });
}
}
// Route-level group check
export function requireGroup(group: string) {
return async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.cognitoUser?.groups.includes(group)) {
reply.status(403).send({ error: "Insufficient permissions" });
}
};
}
Lambda Triggers
// lambda/cognito-triggers/pre-signup.ts
// Block signups from disposable email domains
import type { PreSignUpTriggerEvent } from "aws-lambda";
const BLOCKED_DOMAINS = ["mailinator.com", "guerrillamail.com", "temp-mail.org"];
export async function handler(
event: PreSignUpTriggerEvent
): Promise<PreSignUpTriggerEvent> {
const email = event.request.userAttributes.email ?? "";
const domain = email.split("@")[1]?.toLowerCase() ?? "";
if (BLOCKED_DOMAINS.includes(domain)) {
throw new Error("Sign-ups from this email domain are not allowed.");
}
// Auto-confirm and auto-verify (disable email verification)
// Uncomment for dev environments:
// event.response.autoConfirmUser = true;
// event.response.autoVerifyEmail = true;
return event;
}
// lambda/cognito-triggers/post-confirmation.ts
// Create workspace and user record after confirmation
import type { PostConfirmationTriggerEvent } from "aws-lambda";
import { prisma } from "@/lib/prisma";
export async function handler(
event: PostConfirmationTriggerEvent
): Promise<PostConfirmationTriggerEvent> {
if (event.triggerSource !== "PostConfirmation_ConfirmSignUp") {
return event;
}
const { sub: cognitoSub, email, name } = event.request.userAttributes;
// Idempotent — safe to re-run
await prisma.user.upsert({
where: { cognitoSub },
create: {
cognitoSub,
email,
name: name ?? email.split("@")[0],
workspace: {
create: {
name: `${name ?? email.split("@")[0]}'s Workspace`,
slug: generateSlug(email),
plan: "free",
},
},
},
update: {},
});
return event;
}
function generateSlug(email: string): string {
const base = email.split("@")[0].toLowerCase().replace(/[^a-z0-9]/g, "-");
const suffix = Math.random().toString(36).slice(2, 6);
return `${base}-${suffix}`;
}
// lambda/cognito-triggers/pre-token.ts
// Add custom claims to the JWT before it's issued
import type { PreTokenGenerationV2TriggerEvent } from "aws-lambda";
import { prisma } from "@/lib/prisma";
export async function handler(
event: PreTokenGenerationV2TriggerEvent
): Promise<PreTokenGenerationV2TriggerEvent> {
const cognitoSub = event.userName;
const user = await prisma.user.findUnique({
where: { cognitoSub },
include: { workspace: { select: { id: true, plan: true } } },
});
if (user?.workspace) {
event.response = {
claimsAndScopeOverrideDetails: {
idTokenGeneration: {
claimsToAddOrOverride: {
"custom:plan": user.workspace.plan,
"custom:organization_id": user.workspace.id,
},
},
accessTokenGeneration: {
claimsToAddOrOverride: {
"custom:plan": user.workspace.plan,
"custom:organization_id": user.workspace.id,
},
},
},
};
}
return event;
}
⚙️ DevOps Done Right — Zero Downtime, Full Automation
Ship faster without breaking things. We build CI/CD pipelines, monitoring stacks, and auto-scaling infrastructure that your team can actually maintain.
- Staging + production environments with feature flags
- Automated security scanning in the pipeline
- Uptime monitoring + alerting + runbook automation
- On-call support handover docs included
Hosted UI Auth Flow (Next.js)
// app/auth/login/route.ts — Redirect to Cognito Hosted UI
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import crypto from "crypto";
const HOSTED_UI_DOMAIN = process.env.COGNITO_HOSTED_UI_DOMAIN!;
const CLIENT_ID = process.env.COGNITO_CLIENT_ID!;
const REDIRECT_URI = `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`;
export async function GET() {
const state = crypto.randomBytes(16).toString("hex");
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");
// Store verifier in cookie for callback
const cookieStore = await cookies();
cookieStore.set("pkce_verifier", codeVerifier, { httpOnly: true, secure: true, maxAge: 300 });
cookieStore.set("oauth_state", state, { httpOnly: true, secure: true, maxAge: 300 });
const url = new URL(`${HOSTED_UI_DOMAIN}/oauth2/authorize`);
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "email openid profile");
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return NextResponse.redirect(url);
}
// app/auth/callback/route.ts — Exchange code for tokens
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const code = searchParams.get("code");
const state = searchParams.get("state");
const cookieStore = await cookies();
const codeVerifier = cookieStore.get("pkce_verifier")?.value;
const savedState = cookieStore.get("oauth_state")?.value;
if (!code || !codeVerifier || state !== savedState) {
return NextResponse.redirect(new URL("/auth/error", req.url));
}
// Exchange code for tokens
const tokenRes = await fetch(
`${process.env.COGNITO_HOSTED_UI_DOMAIN}/oauth2/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: process.env.COGNITO_CLIENT_ID!,
redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
code,
code_verifier: codeVerifier,
}),
}
);
if (!tokenRes.ok) {
return NextResponse.redirect(new URL("/auth/error", req.url));
}
const tokens = await tokenRes.json();
// Store tokens in secure httpOnly cookies
const res = NextResponse.redirect(new URL("/dashboard", req.url));
res.cookies.set("access_token", tokens.access_token, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: tokens.expires_in,
});
res.cookies.set("refresh_token", tokens.refresh_token, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 30 * 24 * 60 * 60, // 30 days
});
// Clean up PKCE cookies
res.cookies.delete("pkce_verifier");
res.cookies.delete("oauth_state");
return res;
}
Cost Estimates
| Scope | AWS Cost | Dev Cost | Timeline |
|---|---|---|---|
| Basic setup (up to 50K MAU) | Free (Cognito free tier) | $1,500–3,000 | 1 week |
| + Social login (Google, GitHub) | Free | $800–1,500 | 3–5 days |
| + Lambda triggers (custom flows) | ~$0.20/1M requests | $1,000–2,000 | 3–5 days |
| 100K MAU | ~$275/month | — | — |
| 1M MAU | ~$2,750/month | — | — |
| + SAML/OIDC enterprise federation | $0.015/MAU (federated) | $3,000–6,000 | 1–2 weeks |
Key Cognito gotchas:
- Custom attributes (
custom:*) must be defined at User Pool creation — you cannot add them later without recreating the pool token_useclaim must be validated — access tokens and ID tokens are both JWTs but have different intended audiences- JWKS are cached by jose automatically; do not re-fetch on every request
- Delete user in Cognito and your DB — deleting from one without the other causes auth failures
See Also
- AWS Lambda Scheduled Functions with EventBridge
- AWS Parameter Store vs Secrets Manager
- Next.js Middleware Authentication Patterns
- SaaS Role-Based Access Control
- AWS CloudTrail Audit Logging
Working With Viprasol
Cognito is powerful but the learning curve is steep — misconfigurations in JWT verification, attribute mapping, or Lambda trigger handling lead to subtle auth bugs that are hard to reproduce in production. Our team has Cognito deployments in production for SaaS products with Google/GitHub federation, enterprise SAML, MFA enforcement, and custom token claims.
What we deliver:
- Terraform User Pool and App Client setup
- Correct JWT verification with JWKS caching (jose)
- Social provider federation (Google, GitHub, Microsoft)
- Lambda triggers: pre-signup validation, post-confirmation user creation, pre-token custom claims
- PKCE auth code flow for Next.js frontend
Talk to our team about your Cognito authentication setup →
Or explore our cloud infrastructure services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need DevOps & Cloud Expertise?
Scale your infrastructure with confidence. AWS, GCP, Azure certified team.
Free consultation • No commitment • Response within 24 hours
Making sense of your data at scale?
Viprasol builds end-to-end big data analytics solutions — ETL pipelines, data warehouses on Snowflake or BigQuery, and self-service BI dashboards. One reliable source of truth for your entire organisation.