The situation
The app has three concrete requirements. First, users must be able to create an account with email and password, confirm their email, reset a forgotten password, and sign in on a new device; on top of that, “Continue with Google” and “Continue with Apple” buttons skip the password flow entirely, and the app treats a Google-authenticated user and a locally-registered user as the same kind of user with the same identifier in the database.
Second, when a user takes a photo, the mobile app uploads it directly to S3, not to a Lambda that relays the bytes, but straight to s3.amazonaws.com from the handset, using the AWS SDK for Swift or Kotlin to call PutObject from device code.
Third, the free tier can write to s3://app-photos/users/{sub}/free/*; paid-tier subscribers can write to the free prefix and to s3://app-photos/users/{sub}/premium/*. A user’s tier is a property of their account, not something the client chooses at call time: a free-tier user should not be able to write to the premium prefix just by asking nicely.
The constraint tying it all together: no backend service in the middle. Direct-to-S3 means the device needs real AWS credentials. Which service hands them out, which service proves the user is who they say they are, which service holds the email-and-password directory, and which one enforces the free/paid split, those four questions have different answers, and the team currently uses one name for all of them.
What actually matters
Before reaching for a Cognito page, it’s worth naming what’s actually on the table. The device needs two different artefacts for two different destinations. S3’s IAM evaluation doesn’t understand JWTs, it understands SigV4-signed requests backed by temporary AWS credentials from STS. The team’s REST API (likes, comments, profile) does the opposite: it’s happy with a JWT in the Authorization header and has no interest in STS credentials. One identity, two downstream shapes, two services.
Ownership is the mobile platform team, and the tier logic lives in their billing system. A user moves from free to paid via a webhook from the subscription provider; the system must then translate “paid user” into “can write to the premium prefix” without the client opting in. Anything that lets the client declare their own tier is a non-starter, the authorisation decision has to be made on something the user cannot tamper with.
Blast radius matters: the S3 bucket holds every user’s photos, so a misconfigured policy could let one user write to another user’s prefix. That makes a per-caller identity variable the keystone of the design, something that reduces a “free-tier can write to their own folder” rule to a single policy statement rather than a lookup table of per-user IAM policies.
Cost shape isn’t really the question for Cognito, the pricing is low enough that the team has bigger things to optimise. What matters is that the design doesn’t introduce a backend service in the upload path, because those bytes are the expensive thing and proxying them through Lambda would burn money and time.
Failure modes are mostly about onboarding and tier transitions. A user signs up with Google today, then wants to use email-and-password tomorrow with the same account, both should resolve to the same sub. A user upgrades from free to paid; credentials refresh within the hour, and the next PutObject on the premium prefix succeeds. Getting that seamless depends on putting tier in a place the user can’t modify and a place the credentials stream can see.
Coupling between authentication and authorisation is the confusion the scenario exists to clear up. Authenticating a user and giving that user AWS permissions are two decisions, taken by two different Cognito components, chained together. The shape of the design falls out naturally once the two questions are separated.
What we’ll filter on
Five filters, one per capability the solution has to cover:
- Authenticates users. Holds a user directory, validates passwords, issues confirmation codes, runs sign-in. The thing that proves “yes, this is alice@example.com and she knows her password”.
- Issues AWS credentials. Hands out short-lived AccessKeyId / SecretKey / SessionToken triples the AWS SDK can use to sign API requests.
- Signs API Gateway calls. Something has to tell API Gateway “this call is from Alice, and here’s proof”. The authorising artefact the API method consumes.
- Supports direct-to-S3 upload. Credentials that work against
s3.amazonaws.comdirectly, meaning IAM-signed requests, not a bearer token S3 doesn’t know how to evaluate. - Per-tier authorisation. The same authenticated identity maps to different AWS permissions depending on subscription state, driven by a server-controlled signal.
The Cognito landscape
Cognito User Pool. A managed user directory. Holds usernames, hashed passwords, email addresses, phone numbers, MFA settings, custom attributes. Runs sign-up / confirm / sign-in / password-reset flows. Issues JWTs, an ID token (who the user is), an access token (what scopes they have in the app), a refresh token. OIDC-compliant identity provider in its own right. Can federate to social IdPs (Google, Apple, Facebook, Amazon) and enterprise IdPs (SAML, OIDC). Outputs JWTs, not AWS credentials.
Cognito Identity Pool. A federated-identity broker. Takes a token from a trusted identity provider, a Cognito User Pool, Google, Apple, Facebook, a SAML IdP, a custom developer-authenticated identity, and exchanges it for temporary AWS credentials via STS. Maps each identity to an IAM role: an authenticated role for users who presented a valid token, and an optional unauthenticated role for guests. Outputs AWS credentials, not user accounts.
Federation providers on the User Pool. Social (Google, Apple, Facebook, Amazon) and enterprise (SAML, OIDC, Okta, Entra ID) providers. The User Pool acts as the service provider to the external IdP, brokers the federation, and mints its own JWTs on top. The app only ever sees the User Pool’s tokens regardless of which external IdP the user signed in with.
IAM roles attached to the Identity Pool. Plain IAM roles with trust policies naming cognito-identity.amazonaws.com as the federated principal. Authenticated role permissions are what a signed-in user’s credentials carry; unauthenticated role permissions are what a guest’s credentials carry. Either role can be swapped per user via rule-based mapping or “choose role from token”.
API Gateway / AppSync Cognito authorisers. Consume a User Pool JWT, typically the ID or access token, from the Authorization header. Validate signature, issuer, audience, expiry. Forward user claims to the integration. Do not involve the Identity Pool.
STS AssumeRoleWithWebIdentity. The underlying STS API that exchanges a federated token for AWS credentials. Called explicitly by the Identity Pool’s basic (classic) flow, or internally by the enhanced flow’s GetCredentialsForIdentity. Returns real STS credentials usable against every AWS service that accepts SigV4.
Two sentences to underline. User Pools deal in JWTs; Identity Pools deal in IAM credentials. A User Pool token is useful to a backend or to services that natively understand JWT (API Gateway’s Cognito authoriser, AppSync). It is not a credential the AWS SDK can use to sign a PutObject call. The User Pool is an identity provider, including to the Identity Pool. The same Identity Pool can accept Google or Apple tokens directly, but the app loses the unified user record and has to re-federate separately for the API-Gateway case.
Side by side
| Option | Authenticates | Issues AWS creds | Signs API Gateway | Direct-to-S3 | Per-tier authz |
|---|---|---|---|---|---|
| User Pool alone | ✓ | ✗ | ✓ | ✗ | , |
| Identity Pool alone (federating direct to Google/Apple) | ✗ | ✓ | ✗ | ✓ | ✓ |
| IAM user per app user | , | ✓ | ✗ | ✓ | ✓ |
| User Pool + Identity Pool, group-driven role mapping | ✓ | ✓ | ✓ | ✓ | ✓ |
Matching the flow
Group-to-role mapping, in depth
The role trust policy for FreeTierRole pins the role to this specific Identity Pool and rejects unauthenticated principals:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "cognito-identity.amazonaws.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"cognito-identity.amazonaws.com:aud": "ap-southeast-2:EXAMPLE-identity-pool-id"
},
"ForAnyValue:StringLike": {
"cognito-identity.amazonaws.com:amr": "authenticated"
}
}
}]
}
PaidTierRole uses the same trust policy. Their identity policies differ. FreeTierRole scopes writes to the user’s own free prefix:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::app-photos/users/${cognito-identity.amazonaws.com:sub}/free/*"
}]
}
PaidTierRole grants the same plus the premium prefix. The ${cognito-identity.amazonaws.com:sub} policy variable is the Identity Pool’s identity ID, stable across sessions for the same user, different from the User Pool’s sub, and not modifiable by the client. Even if a free-tier user hand-crafted a PutObject for another user’s prefix, the policy variable resolves server-side to the caller’s identity, so the write would be denied.
Tier upgrades are a server-side flip. The backend calls AdminAddUserToGroup to add the user to paid-tier and AdminRemoveUserFromGroup to take them out of free-tier, then either forces a token refresh or waits for the short-lived token to expire. The next JWT carries paid-tier in cognito:groups; the Identity Pool, set to “choose role from token”, picks PaidTierRole; the premium prefix becomes writable on the next credential exchange. The client can’t opt itself in, the group membership is controlled by the backend.
The API Gateway path, by contrast
Profile edits, likes, and comments don’t need AWS credentials on the device, they go through a REST API the team owns. API Gateway’s Cognito User Pool authoriser is the right fit there, and it bypasses the Identity Pool entirely. The app attaches the User Pool ID or access token to the Authorization header; the authoriser validates the JWT against the User Pool’s JWKS, confirms issuer, audience, and expiry, and if configured checks OAuth scopes or group membership. On success, the user’s claims are forwarded to the integration (Lambda, HTTP backend) as part of the request context. Two paths, one source of truth: the User Pool issues tokens, S3 uploads route through the Identity Pool to get IAM credentials, API Gateway validates the JWT directly.
What’s worth remembering
- User Pool = authentication. Identity Pool = AWS-credential exchange. Two services, two jobs. The names are similar; the outputs (JWTs vs STS credentials) are completely different.
- User Pools are OIDC-compliant identity providers. They can federate to Google, Apple, Facebook, Amazon, SAML, and generic OIDC in front of their own directory, minting their own JWTs on top so the app sees one consistent user identity regardless of upstream.
- Identity Pools federate to trusted identity providers. A User Pool can be one; Google, Apple, and SAML IdPs can be others. The Identity Pool exchanges a trusted token for AWS credentials via
sts:AssumeRoleWithWebIdentity. - The enhanced flow hides the STS call.
GetId+GetCredentialsForIdentityis two API calls that internally reach STS. The basic (classic) flow exposes a third call and lets the app invoke STS directly, more control, more ceremony. - Identity Pools have an authenticated role and an unauthenticated role. Authenticated for users with valid tokens; unauthenticated for guests.
- Per-identity role selection beyond the default. Rule-based mapping (claims-based) and “choose role from token” (group-based, via
cognito:preferred_roleandcognito:roles) let one Identity Pool hand out different roles to different users. - Wire User Pool groups to IAM roles to enforce tiers. Free and paid groups, each pointing at a different role ARN; Identity Pool set to “choose role from token” so group membership drives credential permissions.
- API Gateway / AppSync Cognito authorisers consume User Pool JWTs directly. They don’t go through the Identity Pool. The JWT sits in
Authorization; claims forward to the backend. ${cognito-identity.amazonaws.com:sub}is a per-user policy variable. Scope role permissions to a caller’s own prefix without minting one IAM policy per user.- Only trust claims the user can’t modify when mapping to privileged roles. Group membership is administrator-controlled; a mutable custom attribute is not. Don’t gate a premium tier on something the user can flip client-side.