# Why Cognito resource servers, scopes, and M2M clients?
If you have only ever used Cognito for people signing into apps, this page will feel unfamiliar. That is expected. Notifications V2 asks Cognito to do a second job: prove that a backend is allowed to publish notifications.
This is the “why” guide. The click-by-click “how” is in cognito-setup.md. For the full production reference (safety assessment, checklists, rotation, rollback, AWS links), see Cognito M2M authentication for notifications.
M2M means machine-to-machine: a backend or job talking to an API as itself, with no human logged in.
# Why we never needed this before
In V1 (Video Library era), publishing often looked like:
Backend → POST webhook → anonymous shared API key (x-api-key)
That was simple, and the problems with that specific pattern are why V2 changed the default:
| V1 approach | Problem |
|---|---|
| One anonymous secret duplicated across two projects | Hard to rotate; anyone with the key can forge notifications |
| No real “who published this?” | Keys were not named; no stable source |
| Implicit trust + often a permissive DB insert | The edge function / table largely trusted that the caller was Assess |
V2 retired that VL shared-key + open-table model. The notifications Worker is the only writer. Producers authenticate with Cognito (OpenID Connect / OIDC JWTs — signed access tokens) or with named publisher API keys on the Worker (PUBLISHER_API_KEYS — see api-keys.md). Named keys do have a place: easy bootstrap, simple jobs, no Cognito M2M ceremony. What we reject is “one secret everyone shares with no identity.”
Machine identity for Cognito is “resource server + scope + confidential app client.” For API keys it is name:secret → source = name.
# The two jobs Cognito has for notifications
flowchart TB
subgraph humans [Job 1 — people]
User[Human user]
UserClient[Existing app client<br/>e.g. auth-service-…-user-pool-client]
User -->|login / Hosted UI| UserClient
UserClient -->|user access token| Inbox[Read own inbox / self-notify]
end
subgraph machines [Job 2 — backends]
Job[Assess / cron / API]
M2M[New confidential app client<br/>client id + client secret]
Job -->|client credentials| M2M
M2M -->|M2M access token<br/>scope notifications/publish| Publish[Publish to any user]
end
Inbox --> API[Notifications Worker]
Publish --> API| Job | Who | Which Cognito app client | Token type | What they can do on notifications |
|---|---|---|---|---|
| 1. Inbox | End user in a browser | Your existing user-login client (often no client secret) | User access token | List / mark read / delete own inbox; optional /self |
| 2. Publish | Backend / job | A new confidential M2M client (has client secret) | Client-credentials token with scope notifications/publish |
POST /v2/notifications to any userGuid |
Important: the app client you already use for login (Client secret = -, human login flows) is Job 1. It is not broken, and you should not turn it into an M2M client. Create a second app client for Job 2.
Adding that second client to a production user pool is safe and additive — it does not change users, log anyone out, or alter the existing login client. Details below.
# Plain-language glossary
# OpenID Connect (OIDC)
A standard built on top of OAuth 2.0 for identity: Cognito issues signed tokens that say who (or which app) is calling. When docs say “OIDC JWT,” they mean “a Cognito-signed access token the Worker can verify.”
# JSON Web Token (JWT)
The token format itself: three Base64 segments (header.payload.signature). You can paste one into jwt.io to read claims like sub, client_id, and scope. The Worker rejects tokens with a bad signature, wrong issuer, or expired exp.
# Machine-to-machine (M2M)
Backend-to-API authentication with no user present. Cognito’s name for the flow is client credentials.
# User pool
The directory of users (accounts, passwords, groups). You already have this. Notifications V2 reuses it — we do not create a new pool for notifications.
# App client
A registration that says “this application is allowed to ask Cognito for tokens.”
- Public / user clients — browsers/mobile; often no secret; human login flows (password, Hosted UI, etc.).
- Confidential / M2M clients — servers only; have a secret; can use client credentials.
Think of app clients as door keys. Different doors (login vs machine publish) should have different keys.
# Client ID
Public identifier of an app client. Safe to put in server config (still don’t put M2M secrets in the browser). On every notification the Worker stores this as source for M2M publishes (“which app wrote this?”).
# Client secret
Password for a confidential app client. Cognito shows - when the client has none. Only confidential clients get a secret. If yours shows -, that client cannot do the M2M token curl — create a new confidential client instead of hunting for a missing secret.
# Access token
A short-lived JWT the Worker verifies (signature, issuer, expiry). Claims inside tell us who you are (sub, client_id) and what you’re allowed to do (scope, sometimes cognito:groups).
# Client credentials (M2M)
An OAuth 2.0 grant where the app authenticates as itself with Client ID + Client secret — no human login. Perfect for “Assess finished a review; notify user X.”
# Scope
A permission string on the access token, e.g. notifications/publish. The Worker’s PUBLISHER_SCOPES allowlist must include that string or publish returns 403.
# Resource server (this is the confusing Cognito name)
In Cognito, a resource server is not a machine you deploy. It is a named catalogue of custom scopes that belong to an API.
You create:
| Field | Example | Meaning |
|---|---|---|
| Identifier | notifications |
Namespace for scopes |
| Custom scope | publish |
One permission |
| Full scope string | notifications/publish |
What tokens actually carry / what you put in PUBLISHER_SCOPES |
Why Cognito requires this: custom scopes cannot float free; they must hang under a resource-server identifier. That is an OAuth / Cognito rule, not something Inovus invented for fun.
Resource server "notifications"
└── scope "publish"
└── appears on tokens as: notifications/publish
# Domain (Cognito domain)
The hostname for /oauth2/token (e.g. https://myprefix.auth.eu-west-2.amazoncognito.com). Different from AUTH_ISSUER (https://cognito-idp.…amazonaws.com/poolId). Token requests go to the domain; the Worker trusts tokens whose iss matches AUTH_ISSUER.
# PUBLISHER_SCOPES (Worker env)
“Accept publish if the token’s scope contains one of these strings.”
Normal production setting: PUBLISHER_SCOPES=notifications/publish
You create a scope, not a user group.
# PUBLISHER_GROUPS (Worker env)
“Accept publish if the user token’s cognito:groups contains one of these group names.”
Optional. Only for humans publishing with interactive login. M2M tokens do not have groups — scopes are the path for backends. You do not need to create notification-publishers unless you want human publishers.
# What a resource server is (and is not)
| It is | It is not |
|---|---|
| A Cognito configuration object that defines API permission names (scopes) | A second Cloudflare Worker or server you run |
| Required by Cognito before custom scopes exist | Something end users interact with |
| Shared by any app client you allow to request those scopes | Tied to only one app client forever |
Analogy: the resource server is a label printer for permissions. The M2M app client is a badge that is allowed to wear the notifications/publish label. The notifications Worker checks the badge.
Without a resource server, Cognito has nowhere to attach notifications/publish, so M2M clients cannot request that scope, and the Worker cannot distinguish “allowed publisher” from “random valid user token.”
# End-to-end: what happens when a backend publishes
sequenceDiagram participant Assess as Backend job participant Cognito as Cognito token endpoint participant API as Notifications Worker participant DB as Database Assess->>Cognito: POST /oauth2/token<br/>Basic(client_id:client_secret)<br/>grant_type=client_credentials<br/>scope=notifications/publish Cognito-->>Assess: access_token (JWT) Assess->>API: POST /v2/notifications<br/>Authorization: Bearer access_token API->>API: Verify JWT (iss, sig, exp) API->>API: scope intersects PUBLISHER_SCOPES? API->>DB: insert for userGuid API-->>Assess: 201 created
- Backend proves identity with Client ID + Client secret (never shown to browsers).
- Cognito returns a JWT that includes
scope: notifications/publishandclient_id: …. - Worker verifies the JWT signature using Cognito’s public keys (JWKS — JSON Web Key Set, published by the user pool).
- Worker checks
PUBLISHER_SCOPES. - Worker sets notification
sourcefromclient_id(cannot be spoofed by the JSON body).
That last point is why Cognito M2M beats an anonymous shared key: identity and permission are in a signed token Cognito issued. Named Worker API keys also carry identity (the key name → source); see api-keys.md when M2M setup is more than you need.
# What you already have vs what to add
Using your existing production login client as an example:
| What you see on the user-login client | Meaning |
|---|---|
Client secret = - |
Public-style / no secret — expected for browser (SPA — single-page app) login |
| Flows: password / Hosted UI / similar | Human authentication — expected |
| No “Client credentials” | Cannot mint M2M tokens — expected |
| What to add (new) | Why |
|---|---|
Resource server notifications + scope publish |
Defines the permission name |
New confidential app client (with secret) + client credentials + allow notifications/publish |
Lets backends get publisher tokens |
Worker AUTH_ISSUER = this pool |
Worker trusts tokens from this directory |
Worker PUBLISHER_SCOPES=notifications/publish |
Worker accepts that scope as “may publish” |
Leave the existing login client alone.
# Is it safe to add this to a production user pool?
Yes — if you add new objects and do not reconfigure the existing login client.
Safe (additive):
- Create resource server + scope
- Create a new confidential app client for notifications M2M
- Allow that client the
notifications/publishscope only
Avoid while doing this:
- Editing the existing user-login client’s flows/secret
- Changing pool password policy, MFA, Lambda triggers, or Hosted UI domain “while you’re in there”
- Putting the new client secret in frontend code
- Giving every user the publish scope
Impact on existing apps: none, until some backend starts calling Cognito with the new client’s credentials and then calling the notifications API.
# Do end users need any of this?
No.
- Reading the bell / inbox → ordinary user access token from the login client you already have.
- Optional “notify myself” in the browser →
POST /v2/notifications/selfwith that same user token (no publisher scope). - Cross-user publish from a job → M2M client + scope only.
Never grant notifications/publish to the browser app so “the front end can notify anyone” — that would let any logged-in user publish to any userGuid.
# Minimal checklist (production)
- [ ] Confirm existing login app client stays as-is (no secret required there).
- [ ] Create resource server identifier
notifications, scopepublish. - [ ] Create new confidential app client; enable client secret + client credentials; allow
notifications/publish. - [ ] Copy Client ID + Client secret into a secrets store.
- [ ] Note Cognito domain for
/oauth2/token. - [ ] Set Worker
AUTH_ISSUERandPUBLISHER_SCOPES=notifications/publish(omitPUBLISHER_GROUPSunless you want human publishers). - [ ] Fetch a token with client credentials; decode at jwt.io; confirm
scopeandiss. - [ ]
POST /v2/notificationswith that token.
Step-by-step console UI: cognito-setup.md.
Local send/receive dry run (API keys first): local-development.md. Optional M2M on the laptop: §3.
Production change controls: Cognito M2M reference.
# FAQ
# “Can’t we just use the user pool client we already have?”
Not for M2M publish. No secret + no client-credentials flow means Cognito will not issue the machine token the Worker expects for cross-user publish.
# “Can’t the backend log in as a fake user?”
Please don’t. That conflates human identity with service identity, complicates rotation, and breaks the source = client_id model. M2M exists specifically for this.
# “Is a resource server another AWS billable service?”
No. It is configuration inside the user pool (scopes catalogue).
# “Do I need the notification-publishers group?”
Only if humans must publish with their interactive user token. Backends use scopes. Most installs never create the group.
# “Will creating the M2M client affect single sign-on (SSO) / existing apps?”
No. New client, new credentials, unused until you use them.