Understanding our Cognito Auth Design: ID vs Access Tokens, Permissions, and Future Scope
Hey team,
This is a comprehensive note on how we’re handling authentication & authorization in our backend APIs using AWS Cognito, why we’re doing it this way, where we diverge from standard OAuth2 patterns, and what improvements or considerations we might look at in future.
This is both a primer (for anyone who needs to understand the basics) and a deeper clarification of decisions we’ve made — plus common confusions answered.
What is our current approach?
1. Multiple Cognito User Pools
-
We have separate user pools for each application context:
- Example: Admin portal, Merchant app, Wallet users, etc.
-
This cleanly isolates different kinds of users:
- Admin pool: has
admins,agents, etc. - Merchant pool: has
merchants,merchant-staff.
- Admin pool: has
-
Keeps password policies, MFA, and group logic separated.
This also means an admin user cannot accidentally sign in on merchant app with same credentials — each pool is entirely separate.
2. ID Token as the Main Auth Token
-
We use Cognito ID tokens (JWTs) as the
Authorization: Bearer <token>header in almost all API requests. -
The ID token carries:
email,sub,namecognito:groups(likeadmins,support,merchant)- custom attributes like
custom:org_id,custom:role
So all our APIs use:
const claims = event.requestContext.authorizer.claims;
const email = claims.email;
const groups = claims["cognito:groups"] || [];
This allows us to:
- Know who the user is (identity).
- Enforce permissions by checking groups or querying DB for their specific roles / flags.
3. Custom DB-driven & Cognito-driven permissions
-
We combine:
- Cognito groups (like
admins) inside the ID token. - Plus database-level flags for more granular checks: e.g.
can_add_user,can_manage_stock.
- Cognito groups (like
This means actual authorization decisions are enforced at application logic level, after decoding the ID token.
So what about Access Tokens?
What we are not doing
-
We don’t use Access Tokens to carry OAuth2 scopes like
read:inventoryorwrite:recharge. -
The Access Token is not even passed to our backend APIs.
- It’s only used by the client SDK (Amplify, Cognito JS SDK) to call Cognito user info, refresh sessions, etc.
Is our approach wrong?
No — it’s perfectly valid if you’re careful.
- The ID token does include groups and basic attributes, so we can check permissions there.
- We always verify the ID token signature using API Gateway authorizer, so no one can fake it.
But there are some tradeoffs vs standard OAuth2 practices, and scenarios we bypass:
How do typical OAuth2 / OIDC apps handle this?
| Meant for | Typical lifetime | Used for | |
|---|---|---|---|
| ID Token | For frontend only | ~1h | Display name/email, UI |
| Access Token | For backend APIs | ~15m | To authorize resource access |
| Refresh Token | Client side only | ~30d or more | Get new ID / Access tokens |
In a standard flow:
-
ID token → Used by client app to show user info like
Hello John! -
Access token → Sent to APIs as
Bearerto decide:- Can this user
POST /users? - Can they
PATCH /stock?
- Can this user
-
Refresh token → Used by frontend to silently get new tokens without forcing login.
Why did we not use Access Token for APIs?
- Simplicity: The ID token already has
groupsand user info. Why duplicate? - No OAuth2 scopes are defined for our resources. Our permissions are in DB.
- Less moving parts (no separate
scopechecks).
Downsides (or what we are bypassing)
| Simpler implementation | Not following OAuth2 resource server model |
| All checks under our control | Cannot use API Gateway resource scopes |
| Easy to enforce groups/DB roles | Harder to plug into third-party API ecosystems that expect Access Tokens |
| ID token lives slightly longer | Less strict short-lived access enforcement |
Are we safe?
Yes, because:
- We explicitly check
cognito:groupsor DB roles on every sensitive endpoint. - We keep ID token lifetimes short (~5 min!), to minimize risk if a user is disabled.
In fact, we’ve reversed typical OIDC durations:
- We keep ID token lifetime very short (like 5 mins).
- Access tokens (which we ignore) typically live ~1h by default.
This means if an admin user is deactivated in Cognito, their ID token becomes invalid quickly.
When might we revisit Access Tokens?
- If we ever build external partner APIs where scopes matter, or if we want to enforce
write:rechargevsread:rechargewithout DB lookups, Access Tokens are the standard approach. - Or if we want to use API Gateway
resource serverswithrequired scopes.
Common confusions & FAQs
Can the frontend pass just ID token to call backend?
Yes, that’s exactly what we do.
- The API Gateway validates the ID token signature using the user pool’s JWKs.
Can the frontend also see the access token?
Yes, but it’s mostly for calling Cognito-protected AWS services (like cognito:GetUser).
What happens if we don’t use access token?
-
Nothing breaks. But it means:
- No OAuth2 standard scopes like
write:stock. - All permission logic must live in DB or ID token groups.
- No OAuth2 standard scopes like
Could a valid ID token be replayed from a different device?
- Technically yes, if someone steals the token.
- That’s why we keep it short-lived, enforce HTTPS, and can manually disable sessions.
What if we use Access Token but also need user identity?
You can absolutely do this. If you decide to switch to using Access Tokens for API access control (to check scopes), but still want user profile info like email, name, etc, you can:
-
Pass both tokens in headers. For example:
Authorization: Bearer <access_token>X-Id-Token: <id_token>
-
Or decode the Access Token if you’ve customized it to include identity claims (by default, Access Tokens don’t have much profile info — mainly sub, issuer, etc).
Most people will still keep the ID token for identity display and the Access Token purely for resource authorization.
Another approach is to call Cognito’s /userinfo endpoint with the Access Token to retrieve identity data. But that’s an extra network hop.
Insights from OAuth2 & OpenID Best Practices (Read Full Article)
We often intuitively think:
“I have an ID token that proves who the user is — I can just use it to secure API calls!”
But as multiple OAuth2 and OIDC standards (and articles like from Auth0) emphasize, this is not how these tokens were designed:
ID Token
- Proof that a user authenticated with your identity provider (like Cognito / Auth0 / Google).
- Contains user identity claims (name, email, etc), intended for the client application (frontend).
- Has an
aud(audience) claim targeting your frontend, not your APIs. - Useful for personalizing UI, showing welcome messages, or checking user profile info on the frontend.
Not meant for accessing APIs.
- Using it to authorize API calls ignores its intended audience, skips scopes, and weakens security.
- If stolen, can be replayed against any API not validating
audproperly.
Access Token
- Proof that the client app was authorized to access a resource (API) on behalf of the user.
- Issued by the Authorization Server specifically to be passed to your APIs.
- Comes with
scopesthat precisely define what operations are allowed. - May be sender-constrained (bound to the original client), reducing replay attacks.
The golden rule:
ID token→ to your client (frontend), to prove identity.Access token→ to your APIs (backend), to prove authorization.
Summary - Reconciling our approach vs industry best practices
You might notice that:
In first part of this blog, I said “we use ID tokens for backend APIs and it’s secure because we handle permissions via DB checks and short expiry.”
But here (Auth0 / OAuth2 best practices), it clearly says “ID tokens are NOT for backend APIs; use access tokens for that.”
So which is right?
Why we still chose ID tokens for backend API access
In our system:
- We have multiple isolated user pools (merchants, internal admins, etc).
- We fetch user identity from the ID token, then run fine-grained DB checks for roles, permissions & ownership.
- We keep ID tokens very short-lived (~5 min) to quickly reflect any account deactivation.
This means we’ve effectively re-implemented many access control checks ourselves in the application code, which makes using the ID token acceptable for our specific environment.
We are secure, because we:
- Don’t trust the token blindly; we still verify permissions in DB on every API call.
- Rotate ID tokens quickly to limit misuse.
But:
It’s more manual work.
It bypasses the natural OAuth2 model where ID token proves identity, access token proves authorization, with scopes enforced by Cognito or another Authorization Server.
Why the standards say “Use Access Token”
OAuth2 + OIDC standards want you to:
- Use ID token for your frontend to show who the user is.
- Use Access token to your backend APIs. Your APIs then validate the token’s
aud,iss,scopeetc, and enforce what operations are allowed.
This is:
- Easier to integrate with external systems, federations or multi-app ecosystems.
- Easier to handle delegated permissions (like only allowing read / write via scopes).
So is it wrong we use ID tokens?
No — it’s a conscious trade-off.
For internal, first-party apps (like ours), where we control the frontend, backend and DB, and we implement our own permission checks, using the ID token is still secure.
But it does break the clean OAuth2 separation of identity vs authorization.
If you have a simple app (or everyone essentially has the same access)
Then even if you just pass the ID token to backend, that’s fine — you’re not losing much because there are no sophisticated scopes or delegated apps anyway.
In short:
We’re secure, but we’ve consciously chosen a more “DIY” model by using the ID token everywhere and enforcing permissions ourselves. If we ever want external clients, delegated scopes, or standards-compliant federation, we’d move to using access tokens on APIs.
References
Would love to hear your thoughts — feel free to comment or share any questions!
