Skip to content
ExponaBLOG
AWS FOR DEVELOPERS · DAY 092 / 100

Authentication with Cognito: AWS lessons from production

Managed user pools for your apps.

Expona Admin7 min read

By day 92 of this journey, you've containerized applications, set up databases, and orchestrated infrastructure. But none of it matters if you can't authenticate users securely. Authentication with Cognito brings managed user identity to your AWS stack, eliminating the need to build and maintain your own auth system from scratch. Today, we'll explore how to leverage Cognito's user pools, tokens, and extensibility to ship production-grade authentication.

User pools — Sign-up, sign-in, MFA and social logins

A Cognito user pool is your managed identity provider. Think of it as a database of users with built-in sign-up and sign-in workflows, password policies, and compliance features—all without writing a single authentication function.

Let me walk through a real scenario. You're building a SaaS platform where users need to register, verify their email, and log in. Without Cognito, you'd write:

  • Password hashing logic
  • Email verification endpoints
  • Session management
  • Rate limiting on login attempts
  • Password reset flows

With a user pool, most of this is handled for you. You create a pool, set password complexity requirements, enable email verification, and that's it. Users can sign up through your frontend; Cognito validates emails and stores credentials securely.

Multi-factor authentication (MFA) is equally straightforward. You can enforce it globally or per user. Cognito supports TOTP (time-based one-time passwords), SMS codes, and email codes. For a healthcare or fintech app, this is non-negotiable.

Social logins are another major win. You configure an identity provider like Google, GitHub, or Facebook in your user pool settings. Users click "Sign in with Google," and Cognito federates the login. The first time a user logs in via Google, Cognito creates a user record in your pool and links it to their Google ID. Subsequent logins are seamless.

Here's how you'd create a user pool and configure Google federation programmatically:

bash
aws cognito-idp create-user-pool \
  --pool-name MyAppUserPool \
  --policies PasswordPolicy=\{MinimumLength=12,RequireUppercase=true,RequireLowercase=true,RequireNumbers=true,RequireSymbols=true\} \
  --email-verification-message "Your code is {####}" \
  --email-verification-subject "Verification code" \
  --mfa-configuration OPTIONAL \
  --user-attribute-update-settings AttributesRequireVerificationBeforeUpdate=\[email\]

Then you'd add a Google identity provider:

bash
aws cognito-idp create-identity-provider \
  --user-pool-id us-east-1_xxxxxxxxx \
  --provider-name Google \
  --provider-type Google \
  --provider-details client_id=your-google-client-id.apps.googleusercontent.com,client_secret=your-google-secret,authorize_scopes="email openid profile"

Hosted UI or custom — Use the managed login pages or build your own

Cognito offers two authentication UI strategies: hosted UI or custom integration.

The hosted UI is a Cognito-managed login page that lives at a URL like https://yourapp.auth.us-east-1.amazoncognito.com/login. It handles sign-up, sign-in, MFA, password reset, and social login flows. You redirect users there, they authenticate, and Cognito redirects them back to your app with authorization codes. This is the fastest path to production and is PCI-DSS compliant out of the box.

The trade-off: limited UI customization. You can rebrand it with your logo and colors, but you can't dramatically redesign the flow.

A custom UI gives you full control. You build your own login form and call Cognito's APIs directly. This requires more code but lets you match your brand exactly. You're responsible for handling MFA displays, error messages, and session management.

For most production apps in the mid-to-senior range, I recommend starting with the hosted UI. It's battle-tested, reduces your attack surface, and you can always add a custom UI layer later. If you need a highly opinionated authentication flow—like progressive profiling during sign-up or custom MFA challenges—then invest in custom integration.

To use the hosted UI, you configure an app client in your user pool:

bash
aws cognito-idp create-user-pool-client \
  --user-pool-id us-east-1_xxxxxxxxx \
  --client-name MyAppClient \
  --callback-ur ls "https://myapp.com/callback" \
  --allowed-o-auth-flows code \
  --allowed-o-auth-scopes email openid profile \
  --supported-identity-providers COGNITO Google

Your frontend then redirects to the hosted UI:

bash
const cognitoAuthUrl = `https://yourapp.auth.us-east-1.amazoncognito.com/login?client_id=YOUR_CLIENT_ID&response_type=code&scope=email+openid+profile&redirect_uri=https://myapp.com/callback`;

After authentication, Cognito redirects back with an authorization code, which your backend exchanges for tokens.

Tokens — ID, access and refresh tokens as JWTs

Cognito issues three tokens: ID, access, and refresh. All are JWTs. Understanding the distinction is critical.

The ID token contains user identity information: username, email, name, and custom attributes. It's meant for your app to know who the user is. It's not for authorization.

The access token is for authorization. You include it in HTTP requests to protected resources. Your API validates the token's signature and scopes to decide if the user can proceed. For example, if an access token has the scope posts:write, your API grants permission to create posts.

The refresh token is long-lived and offline. When the access token expires (typically after 1 hour), your client uses the refresh token to obtain new tokens without asking the user to log in again. This is how you achieve seamless sessions.

Here's a real flow:

  1. User logs in via hosted UI or custom flow.
  2. Cognito returns ID token, access token (1 hour expiry), and refresh token (30 days expiry).
  3. Your frontend stores these tokens (secure, httpOnly cookies recommended).
  4. On every API request, your frontend sends the access token in the Authorization header.
  5. Your backend API verifies the token's signature using Cognito's public keys (cached locally).
  6. When the access token expires, your frontend uses the refresh token to get new tokens.

In Node.js / NestJS, you might use a library like aws-jwt-verify:

bash
import { CognitoJwtVerifier } from "aws-jwt-verify/cognito-verify";

const verifier = CognitoJwtVerifier.create({
  userPoolId: "us-east-1_xxxxxxxxx",
  tokenUse: "access",
  clientId: "YOUR_CLIENT_ID",
});

try {
  const payload = await verifier.verify(accessToken);
  console.log("Token valid:", payload);
} catch (err) {
  console.log("Token invalid:", err);
}

This fetches Cognito's public keys, validates the signature, checks expiry, and ensures the token use matches. All in one call.

Triggers — Lambda hooks for custom validation and migration

Cognito triggers (also called Lambda hooks or Lambda authorizers in some contexts) let you inject custom logic at key authentication moments. You attach Lambda functions to user pool events.

Common trigger scenarios:

  • Pre-sign-up: Validate sign-up data, auto-confirm users from certain domains, block disposable email addresses.
  • Pre-authentication: Check if the user's account is in a suspended status before allowing login.
  • Post-authentication: Log login events, update last login timestamp in your database.
  • Custom message: Customize email or SMS messages sent by Cognito (welcome emails, reset codes).
  • User migration: Migrate legacy users from an old system into Cognito on-demand.

User migration is particularly valuable. If you're moving from a custom auth system, you can't migrate all users upfront—it's slow and risky. Instead, you create a migration Lambda. When a user tries to log in with their old credentials, the pre-authentication trigger calls your Lambda. It checks the legacy database, validates the password, and if valid, creates the user in Cognito. Next login, they're native to Cognito.

Here's a simplified pre-sign-up trigger that auto-confirms users from your company domain:

bash
exports.handler = async (event) => {
  if (event.request.userAttributes.email.endsWith("@mycompany.com")) {
    event.response.autoConfirmUser = true;
    event.response.autoVerifiedEmail = true;
  }
  return event;
};

And a post-authentication trigger that logs login events to DynamoDB:

bash
const AWS = require("aws-sdk");
const dynamodb = new AWS.DynamoDB.DocumentClient();

exports.handler = async (event) => {
  const userId = event.request.userAttributes.sub;
  const timestamp = new Date().toISOString();
  
  await dynamodb.put({
    TableName: "LoginLogs",
    Item: { userId, timestamp, event: "login" },
  }).promise();
  
  return event;
};

Triggers are powerful but easy to overuse. Keep them lightweight and fast—they add latency to authentication. If your trigger takes 3 seconds, login takes 3 seconds longer. For heavy processing, trigger a background job instead.

Common mistakes

Storing tokens in localStorage: It's vulnerable to XSS attacks. Use httpOnly cookies for tokens instead. Your backend sets them, the browser automatically includes them in requests, and JavaScript can't access them.

Not refreshing tokens: Access tokens expire. If your frontend doesn't handle refresh, users get logged out abruptly. Implement token refresh logic before the access token expires (or catch 401 responses and refresh).

Validating tokens on the frontend only: The frontend can't be trusted. Always validate tokens on your backend. The frontend can do an early check for UX, but the backend must verify every request.

Forgetting to rotate refresh tokens: Cognito can rotate refresh tokens automatically. Enable this. It limits the damage if a refresh token is leaked.

Building complex logic in triggers: Triggers should be thin. If you need to call multiple APIs or do complex validation, put that in your backend and have the trigger call your backend synchronously. Triggers add latency; every millisecond counts during authentication.

Key takeaways

  • User pools are Cognito's core feature for managing user identity. They handle sign-up, sign-in, MFA, and social federation with minimal code.
  • Choose the hosted UI for simplicity and security compliance. Opt for custom UI only if you need fine-grained control over the authentication experience.
  • ID tokens identify the user, access tokens authorize requests, and refresh tokens maintain sessions. Treat each distinctly.
  • Validate tokens on your backend using libraries like aws-jwt-verify. Cache Cognito's public keys to avoid external calls on every request.
  • Use Lambda triggers sparingly for pre-sign-up validation, post-authentication logging, and user migration flows. Keep them fast and delegate heavy work to your backend.
  • Store tokens in httpOnly cookies, not localStorage. Implement refresh logic to maintain sessions seamlessly.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles