AWS Cognito Setup with Terraform
Authentication in modern applications is an architectural boundary. It defines how users prove their identity, how applications obtain tokens, how APIs validate incoming requests, and how permissions are enforced across services. When authentication is configured manually in the AWS console without a clear structure, it often leads to inconsistent environments, unclear authorization rules, and fragile integrations between frontend and backend systems.
In this post, we will build a production-oriented AWS Cognito setup using Terraform. However, before diving into code, it’s important to establish a clear mental model of how Cognito components fit together and how tokens actually move through the system.
Without that mental model, Terraform resources become configuration blocks without meaning. With it, they become deliberate architectural decisions.
Understanding the core Cognito components
At its core, Amazon Cognito revolves around a managed identity system called a User Pool. The User Pool is the identity engine of the system. It stores users, manages sign-up and verification flows, handles password recovery, and issues tokens after successful authentication and few other things. Every authentication process ultimately depends on the User Pool.
However, a User Pool on its own doesn’t do much as applications need a defined way to interact with it. That is where the App Client comes in.
An App Client represents a specific application — for example, a web frontend, a mobile application, or a backend service. It defines how that application is allowed to authenticate. This includes the OAuth flow it can use, the scopes it may request, token lifetime settings, and which callback URLs are valid. In other words, the App Client determines the authentication rules for a particular consumer of the identity system.
Once again – authentication answers questions “who are you” and authorization “what are you allowed to do”.
This distinction becomes explicit when introducing a Resource Server. A Resource Server allows us to define custom OAuth scopes such as api/read, api/write, or admin/full. These scopes are embedded in access tokens and allow backend services to enforce fine-grained permissions. Instead of relying only on identity claims, APIs can validate whether the caller has the exact permission required to perform an action.
To enable OAuth flows such as the Authorization Code Flow, Cognito also requires a User Pool Domain. The domain exposes standard OAuth2 endpoints like /authorize, /token, and /logout, and enables the Cognito Hosted UI. Without a domain, OAuth-based authentication cannot function properly.
Together, these components form a complete authentication and authorization system: identity storage, application integration, permission modeling, and standardized OAuth endpoints.
Token flow
In the Authorization Code Flow, the user is redirected to the Cognito Hosted UI to authenticate. After a successful login, Cognito returns an authorization code, which the application exchanges for tokens.
Cognito then issues an ID token (identity information), an access token (API permissions), and a refresh token (session continuity). The frontend sends the access token to the backend API, where it is validated and checked for required scopes before access is granted.
This flow keeps authentication centralized in Cognito while allowing APIs to enforce authorization independently.
Implementing Cognito resources with Terraform
We can now translate this architecture into Terraform. Instead of configuring Cognito manually in the AWS console, we will define:
- The User Pool as the identity engine
- The Resource Server for custom API scopes
- The App Client configured for Authorization Code Flow
- The User Pool Domain to enable OAuth endpoints
User Pool – The Foundation
The User Pool is the foundation of the authentication system. It stores users, manages verification and recovery flows, and issues tokens after successful authentication.
The user schema is part of your system design. Attributes defined here directly influence token claims and downstream authorization logic. Managing them through Terraform prevents configuration drift across environments.
resource "aws_cognito_user_pool" "this" {
name = var.cognito_user_pool_name
auto_verified_attributes = var.auto_verified_attributes
verification_message_template {
default_email_option = var.default_email_option
}
account_recovery_setting {
dynamic "recovery_mechanism" {
for_each = var.cognito_recovery_mechanisms
content {
name = recovery_mechanism.value.name
priority = recovery_mechanism.value.priority
}
}
}
dynamic "schema" {
for_each = var.cognito_schema
content {
attribute_data_type = schema.value.attribute_data_type
mutable = schema.value.mutable
name = schema.value.name
required = schema.value.required
}
}
}Enabling OAuth – User Pool Domain
To support OAuth flows and the Hosted UI, we define a domain to expose standard OAuth endpoints such as /authorize and /token.
resource "aws_cognito_user_pool_domain" "this" {
domain = var.cognito_user_pool_name
user_pool_id = aws_cognito_user_pool.this.id
lifecycle {
create_before_destroy = true
}
}Defining API Permissions – Resource Server
Instead of relying only on default scopes like openid, we define custom API scopes:
resource "aws_cognito_resource_server" "this" {
identifier = var.cognito_resource_server_name
name = var.cognito_resource_server_name
user_pool_id = var.cognito_user_pool_id
scope {
scope_description = "Read access to API"
scope_name = "api/read"
}
}Scopes represent API permissions so not identity claims.This is where authentication and authorization clearly separate
App Client – Authorization Code Flow
Finally, we configure the App Client:
resource "aws_cognito_user_pool_client" "this" {
name = var.cognito_app_client_name
user_pool_id = var.cognito_user_pool_id
allowed_oauth_flows = ["code"]
allowed_oauth_flows_user_pool_client = true
allowed_oauth_scopes = aws_cognito_resource_server.this.scope_identifiers
callback_urls = var.callback_urls
logout_urls = var.logout_urls
enable_token_revocation = true
prevent_user_existence_errors = "ENABLED"
}Here we explicitly allow only the Authorization Code Flow and control token behavior through variables.
What This Setup Enables
With this configuration in place, authentication becomes a clearly defined infrastructure component rather than a console-driven configuration.
This setup allows you to secure Amazon API Gateway using a Cognito Authorizer, validate JWT tokens inside backend services, and enforce scope-based authorization at the API level. Instead of relying solely on identity claims, your APIs can explicitly check whether a caller has the required permissions defined through custom OAuth scopes.
Because everything is defined in Terraform, environments remain consistent. The same User Pool structure, scopes, OAuth flows, and token settings can be deployed across development, staging, and production without configuration drift.
Authentication stays centralized in Cognito, while authorization decisions remain under the control of your application and API layer.
Production Considerations
When moving beyond basic setups, several configuration details become especially important:
- Authorization Code Flow only – More secure than the implicit flow and better suited for modern web applications.
- Token revocation enabled – Allows invalidating sessions when necessary.
- Controlled token lifetimes – Access, ID, and refresh tokens should have intentional expiration policies.
- Custom scopes – Clear separation between identity and API permissions.
Conclusion
Authentication should not be treated as a secondary feature configured manually in the AWS console. It is a core architectural boundary that influences how every request enters your system.
By defining the User Pool, Resource Server, App Client, and Domain explicitly in Terraform, you transform Cognito from a black-box service into a version-controlled, predictable part of your infrastructure.
Identity and authorization become intentionally separated. OAuth flows are explicitly chosen. Token behavior is controlled. Environments remain consistent.
When authentication is managed as infrastructure, it stops being fragile configuration — and becomes deliberate system design.







