You want end users and admin users to authenticate against the same GraphQL API, but with different security policies. One Cognito pool for customers — simple sign-up, social login, minimal friction. Another for admins — stricter passwords, MFA, different token claims. Both hitting the same AppSync endpoint.
Here's the full CloudFormation setup and the one gotcha that almost stopped me from shipping it.
Step 1: Two Cognito User Pools
Resources:
EndUsersPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: end-users-pool
UsernameAttributes:
- email
AutoVerifiedAttributes:
- email
Policies:
PasswordPolicy:
MinimumLength: 8
RequireLowercase: true
RequireNumbers: true
RequireSymbols: false
RequireUppercase: true
Schema:
- AttributeDataType: String
Name: email
Required: true
- AttributeDataType: String
Name: name
Required: false
EndUsersPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
ClientName: end-users-pool-client
UserPoolId: !Ref EndUsersPool
AdminUsersPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: admin-users-pool
UsernameAttributes:
- email
AutoVerifiedAttributes:
- email
Policies:
PasswordPolicy:
MinimumLength: 8
RequireLowercase: true
RequireNumbers: true
RequireSymbols: false
RequireUppercase: true
Schema:
- AttributeDataType: String
Name: email
Required: true
- AttributeDataType: String
Name: name
Required: false
AdminUsersPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
ClientName: admin-users-pool-client
UserPoolId: !Ref AdminUsersPoolStep 2: AppSync API with Both Pools
Resources:
AppSyncApi:
Type: 'AWS::AppSync::GraphQLApi'
Properties:
Name: graphql-api
AuthenticationType: AMAZON_COGNITO_USER_POOLS
UserPoolConfig:
AwsRegion: us-east-1
DefaultAction: ALLOW
UserPoolIds:
- !Ref EndUsersPool
- !Ref AdminUsersPoolStep 3: Schema with Pool-Specific Access
type Query {
endUserQuery: EndUser
adminUserQuery: AdminUser
}
type EndUser @aws_auth(cognito_user_pools: [{userPoolId: "EndUsersPool", groups: ["endUsers"]}]) {
id: ID!
name: String!
}
type AdminUser @aws_auth(cognito_user_pools: [{userPoolId: "AdminUsersPool", groups: ["adminUsers"]}]) {
id: ID!
name:The Gotcha That Almost Stopped Me
You'll find a different syntax in many examples — @aws_cognito_user_pools with just a cognito_groups argument:
type Query {
endUserQuery: EndUser
@aws_cognito_user_pools(cognito_groups: ["endUsers"])
}
When I first saw this, I froze. There's no user pool ID in the directive. How does AppSync know which pool's "endUsers" group to check? If both pools have a group called "endUsers," what happens?
My instinct was to stop and research until I fully understood the mapping. Instead, I tried it. It worked. AppSync resolves the group against whichever pool authenticated the current request.
The lesson: if two pools have identically named groups, results could be unpredictable. But in practice, admin and end-user groups rarely collide. And the syntax is clean enough that it's worth using.
The Real Takeaway
I almost didn't ship this because the directive syntax didn't match my mental model. I wanted to understand why it worked before I let myself use it. That caution has value — but so does the "just try it" approach.
The best developers I've worked with don't wait for complete understanding before building. They build, observe, and adjust. The understanding comes from doing, not from reading documentation until the picture is perfect.
When the docs confuse you, deploy to staging and see what happens. You'll learn more in ten minutes of experimentation than in an hour of reading.
