N.E.S.T. Access Gate (Cognito `nest-access` group)
nest-access group. This page documents the gate’s mechanism, how to add a user, the per-environment pool IDs, and what the SPA must do to read the claim.TL;DR. A Cognito group named nest-access exists on all three Phenom user pools (phenom-dev-local, phenom-staging, phenom-prod). Three independent layers enforce or surface membership: (1) the N.E.S.T. SPA refuses sign-in for non-members, (2) the nest-api Worker rejects any /api/* request from a non-member’s JWT – the real security boundary, and (3) the hasura-cognito-sync-users Lambda mirrors membership into a users.nest_access boolean column on each sign-in so Hasura queries can filter without re-checking Cognito. The group itself stays a pure label – no IAM role, no JWT claim manipulation.
Why this exists
The Phenom Cognito user pools hold every platform user – mobile-app sign-ups, drop visitors, etc. N.E.S.T. (the operator-facing dashboard) is not for end-users; only staff/operators/approved insiders should reach it. Before this gate, that filtering happened entirely at the application layer (or at the CF Access edge for the legacy nest-firebase.thephenom.app surface). Post-Cognito-migration, the SPA needs a Cognito-native signal it can read from the ID token without an extra round-trip – cognito:groups is that signal.
Per-environment pool IDs
| Env | Pool name | Pool ID |
|---|---|---|
| Dev | phenom-dev-local |
us-east-1_AkG9mnbjA |
| Staging | phenom-staging |
us-east-1_n8gO6SbP6 |
| Prod | phenom-prod |
us-east-1_knEL7cqS3 |
All three pools have the nest-access group declared in phenom-infra Terraform. The group ships empty in every pool.
Three enforcement layers (live as of 2026-05-23)
The gate is enforced at three independent layers. Each does a different job; together they cover the surface.
1. SPA – UX bounce at sign-in (phenom-backend admin_sandbox/phenom)
The Zustand auth store’s login(), restoreSession(), and completeNewPassword() handlers decode the ID token’s cognito:groups claim and reject the sign-in if nest-access is missing. Implementation lives in src/stores/authStore.ts:
// Exports
export const NEST_ACCESS_GROUP = 'nest-access'
export class NoNestAccessError extends Error { ... }
// Inside login() / restoreSession() / completeNewPassword():
const payload = session.getIdToken().decodePayload()
if (!hasNestAccess(payload)) {
cognitoSignOut() // purge Cognito storage
set({ isLoading: false })
throw new NoNestAccessError(payload['email']) // typed error for the form
}
The sign-in form (src/features/auth/sign-in/components/user-auth-form.tsx) catches NoNestAccessError and renders a clear “Your account is not authorized to access N.E.S.T. Contact an administrator to request access.” instead of a generic “Authentication failed”, so users don’t loop on their password.
Important caveats:
- The claim is absent – not an empty array – when the user has no groups. Normalize with
?? []before.includes(…). Cognito omits the claim entirely for ungrouped users. - The claim is on the ID token, not the access token. The two are issued together at sign-in.
- This layer is bypassable – a caller who reads the raw JWT out of
localStoragecan hit/api/*directly. That’s why layer 2 exists.
2. Worker – real security boundary (phenom-backend admin_sandbox/nest-api)
The nest-api Cloudflare Worker’s JWT validation middleware checks the same claim after signature/issuer verification, BEFORE proxying to Hasura. Implementation in src/auth.ts:
export const NEST_ACCESS_GROUP = 'nest-access'
// In verifyCognitoJwt() return value, alongside email/sub:
const groups: string[] = Array.isArray(payload['cognito:groups'])
? payload['cognito:groups']
: []
// In authenticate():
const verified = await verifyCognitoJwt(token, env)
if (!verified) return null
if (!isInNestAccessGroup(verified.groups)) {
console.log(`nest-api: rejecting valid Cognito token for ${verified.email} -- not in ${NEST_ACCESS_GROUP}`)
return null
}
A non-member’s request gets a 401 from /api/* even if their JWT is otherwise valid. This is the non-bypassable boundary – independent of the SPA’s check.
3. Lambda – Hasura mirror (phenom-infra hasura-cognito-sync-users)
The hasura-cognito-sync-users Lambda (post_authentication + post_confirmation triggers on all three pools) calls AdminListGroupsForUser after the user signs in and upserts a nest_access boolean field into the Hasura users row alongside the other Cognito attributes:
// In the handler, after building the standard payload:
if (allowedColumns.has('nest_access') && event.userPoolId && event.userName) {
const groups = await listUserGroups(event.userPoolId, event.userName)
payload.nest_access = groups.includes(NEST_ACCESS_GROUP)
}
The Lambda’s IAM role has cognito-idp:AdminListGroupsForUser scoped to the env’s user pool ARNs. Graceful degradation: if the column doesn’t yet exist, GraphQL introspection drops it from the payload (no-op for the new field). If the Cognito API call fails, nest_access defaults to false for this run and resyncs on the next sign-in.
This layer is for query convenience, not enforcement. Hasura clients (the SPA, the action handler Lambda, future apps) can ask “show me all admins” via users(where: { nest_access: { _eq: true } }) without re-querying Cognito or asking the Worker. The source of truth remains the Cognito group; Hasura is a read-only mirror.
How to add a user to the group
Via AWS CLI (preferred – captured in audit logs)
# Dev
aws cognito-idp admin-add-user-to-group \
--region us-east-1 \
--user-pool-id us-east-1_AkG9mnbjA \
--group-name nest-access \
--username <user-email-or-sub>
# Staging
aws cognito-idp admin-add-user-to-group \
--region us-east-1 \
--user-pool-id us-east-1_n8gO6SbP6 \
--group-name nest-access \
--username <user-email-or-sub>
# Prod
aws cognito-idp admin-add-user-to-group \
--region us-east-1 \
--user-pool-id us-east-1_knEL7cqS3 \
--group-name nest-access \
--username <user-email-or-sub>
The new membership takes effect on the user’s next sign-in – JWTs are stateless, so existing tokens don’t auto-update. If you need it to take effect immediately, also call admin-user-global-sign-out to invalidate their current sessions and force a fresh sign-in.
Via AWS Cognito Console
- Open the AWS Console → Cognito → User pools → pick the env’s pool
- Users tab → find the user → Add user to group
- Pick
nest-access→ Add
The Console path produces no audit-log trail beyond CloudTrail; prefer the CLI when granting access to anyone other than yourself for the duration of a smoke test.
How to remove a user
aws cognito-idp admin-remove-user-from-group \
--region us-east-1 \
--user-pool-id <pool-id> \
--group-name nest-access \
--username <user-email-or-sub>
# Then optionally:
aws cognito-idp admin-user-global-sign-out \
--region us-east-1 \
--user-pool-id <pool-id> \
--username <user-email-or-sub>
Removal blocks future sign-ins from gaining access; existing JWTs remain valid until expiry unless you global-sign-out.
What the group is NOT
- Not a Hasura role. Cognito
nest-accessmembership flows into Hasura only via theusers.nest_accesscolumn written byhasura-cognito-sync-users– a data column for queryability, not a role for Hasura’s RBAC engine. (Separately, as of 2026-05-28 the Hasuraadminrole is granted by thenest-adminCognito group via the pre-token-generation Lambda, decoupled fromchat_members.roleand fail-closed; see Teams, admin and infrastructure. The legacychat_members.rolelookup now serves chat tiering only.) - Not an IAM grant. The group has no
role_arn. Membership doesn’t issue AWS-resource credentials. - Not a precedence-ordered tier. No
precedencefield. Group is a pure boolean: in or out. - Not a CF Access policy. Where CF Access is still in front of a N.E.S.T. surface (e.g.
nest-firebase.thephenom.app), users must clear both gates.nest.thephenom.app(prod) currently has no CF Access in front – Cognito + the three layers above are the only auth.
Terraform definition
Three resources, one per pool, all named nest-access at the Cognito display-name layer:
# environments/development/main.tf
resource "aws_cognito_user_group" "nest_access" {
name = "nest-access"
user_pool_id = aws_cognito_user_pool.main.id # phenom-staging
description = "Gate group: members can access the N.E.S.T. dashboard."
}
resource "aws_cognito_user_group" "nest_access_local" {
name = "nest-access"
user_pool_id = aws_cognito_user_pool.local.id # phenom-dev-local
description = "Gate group: members can access the N.E.S.T. dashboard."
}
# environments/production/cognito.tf
resource "aws_cognito_user_group" "nest_access" {
name = "nest-access"
user_pool_id = aws_cognito_user_pool.main.id # phenom-prod
description = "Gate group: members can access the N.E.S.T. dashboard."
}
Membership is administered out-of-band; the group is intentionally empty at apply time.
Hasura schema dependency
Layer 3 (the Lambda mirror) writes to a nest_access column on the users table. The column must be added via a Hasura migration in phenom-backend/hasura/migrations – without it, the Lambda silently no-ops on the new field. Recommended schema:
ALTER TABLE users
ADD COLUMN nest_access boolean NOT NULL DEFAULT false;
Once the column exists and the Lambda has been deployed, populate it for existing users by triggering a re-sync (any sign-in repopulates that user’s row).
Related
- Cognito Auth Flows in the SPA – sign-in, NEW_PASSWORD_REQUIRED, password-reset
- Admin Dashboard – the dashboard this gate protects
- N.E.S.T. Infrastructure – the deployed architecture
- Chat Authentication – how the prod pool integrates with Synapse
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.