Two Platforms Need Two App Clients, Not a Looser Audience Check
Adding Android to a backend built for iOS forced a choice between relaxing JWT audience validation and issuing a second client identity, and the cheaper-looking option was the one that destroyed information.
- Author
- Aaron Smith
- Reading time
- 7 min
ClientName: !Sub "the mobile game-mobile-${Stage}"
That is one line from a CloudFormation template I committed to a mobile game backend one morning this spring. Until that commit the line read mobile-app-ios-${Stage}. Renaming the Cognito app client was the smallest possible way to make a new Android app work against a backend built for iOS: one app client, three identity providers, two callback and two sign-out URLs, and every token from either platform carrying the same aud claim. I reversed it one hour and fifty-four minutes later.
The choice is not specific to Cognito or to mobile. Whenever a second consumer arrives in front of an existing token check, there is a one-line version that relaxes the check and an infrastructure version that issues a second identity. The one-line version really is cheaper on the day. It is also the version that quietly deletes information you will want back.
The merge looked like a rename
The design I wrote that morning is still in the repository as an Android client support note, committed twenty-two minutes ahead of the code. It proposed a single app client serving both platforms: add Google to SupportedIdentityProviders alongside the existing COGNITO and SignInWithApple, append the Android redirect URIs to the same CallbackURLs list, rename the client from ios to mobile. Its central claim was correct and I still agree with it. In bold, in the document:
The backend does not need to know or care whether the user signed in with Apple or Google.
That statement is about the identity provider, and it is true. Without noticing, I generalized it into a claim about the client: that the backend does not need to know or care which app the token came from. Those are different sentences. Only the first one holds.
An audience claim is worth only the set it names
RFC 7519 §4.1.3 defines aud as identifying "the recipients that the JWT is intended for", and requires that each principal processing the token identify itself with a value in that claim. In a Cognito user pool, the value that appears there is the app client ID. AWS's token verification guidance is precise about where it lives: an ID token carries the app client as aud, while an access token carries it as client_id, which is why this middleware also pins token_use to id before trusting anything else in the payload.
With one app client, aud has exactly one legal value. Checking it confirms the token came from your user pool, which the issuer and the signature already confirmed. The claim goes from carrying one bit to carrying none, and nothing in the system reports the loss.
The fix in src/middleware/auth.ts was one line:
// before — one client, so this is a constant
audience: config.COGNITO_CLIENT_ID,
// after — the set of clients registered for this pool
audience: getCognitoClientIds(),
getCognitoClientIds() returns the iOS client ID always, plus the Android client ID when COGNITO_ANDROID_CLIENT_ID is present in the Lambda environment. The verification library treats an array as a membership test rather than a wildcard, so a token minted for an unregistered client still fails; the same two IDs sit in the HTTP API's JWT authorizer audience block, so the check runs at the gateway and again in the function. What actually changed is that per-client operations now have a per-platform scope. Both clients set EnableTokenRevocation: true and cap the ID token at one hour, but those are client-level settings: on a shared client, cutting a token lifetime, pulling an identity provider, or deleting the client to stop a bad release does it to both platforms at once.
Platform differences showed up within three days
The case for one client is that the two platforms are the same thing. Here is what the repository recorded over the next four days.
| When (PDT) | Commit | Change | Applies to |
|---|---|---|---|
| T+0 | One mobile client, three providers, four redirect URLs |
Both | |
| T+1h54m | Split into ios and android clients, aud checked against both |
Both | |
| T+6h34m | Google credentials become template parameters, secret marked NoEcho |
Both | |
| T+2d6h | ALLOW_USER_SRP_AUTH added, Hosted UI login was broken |
Both | |
| T+2d10h | HTTPS callback URL added for WKWebView |
iOS only | |
| T+4d1h | 97-line Google sign-in requirements document | Android only |
Row five is the one that settles the argument. iOS was presenting the Cognito Hosted UI inside a WKWebView, which will not navigate to a custom URL scheme, so the OAuth redirect returned ?error=cancelled. The repair was to register an HTTPS callback and its matching sign-out URL on a domain the project already controlled, against the iOS client alone. Android needed nothing, because Chrome Custom Tabs handle the custom scheme. On the merged client, that repair would have added an HTTPS redirect target to the allowed set for a platform with no use for it, and no field anywhere in the stack would have recorded that it was there for one platform's embedded browser.
What the second client actually cost
The split was not free, though the first bill was not really the split's fault. Managing the Google identity provider in the template meant CloudFormation had to create the resource, and my first version pulled its credentials out of Parameter Store:
client_id: "{{resolve:ssm:/axesoffate/prod/google-client-id}}"
client_secret: "{{resolve:ssm-secure:/axesoffate/prod/google-client-secret}}"
That looks like the careful option, and the second line does not work. AWS documents ssm-secure as supported on an enumerated list of resource properties: eleven properties across ten resource types, every one of them a credential field on a database, a directory, an IAM user or a source repository. AWS::Cognito::UserPoolIdentityProvider is not among them, and neither is any other identity-provider property. Templates that go through the Serverless Application Model transform carry a second problem the same page names: when a transform is in play, CloudFormation hands the literal dynamic reference string to the transform instead of resolving it first, and resolves it only at change-set execution.
By that evening both values were template parameters supplied at deploy time, the secret marked NoEcho. That keeps the secret out of the template and out of DescribeStacks, and it moves the job of retrieving it from Parameter Store out of CloudFormation and into whoever runs the deploy. That is a downgrade, it is still the current state, and I have not fixed it.
Creating the provider also needed a permission the deployment role lacked. The Cognito statement in infra/deploy-policy-1.json granted cognito-idp:*UserPool*, which does not match CreateIdentityProvider; one pattern, cognito-idp:*IdentityProvider*, was added beside it, still scoped by resource to user pools in one region.
The cost that recurs, though, is duplication. Two days later, native username-and-password sign-in through the Hosted UI was failing, because both clients had been created with ExplicitAuthFlows set to ALLOW_REFRESH_TOKEN_AUTH and nothing else, and the Hosted UI performs that sign-in over the Secure Remote Password exchange. The repair, ALLOW_USER_SRP_AUTH, went into the iOS client and then into the Android client, in one commit, forty-two lines apart. One mistake, made once, corrected twice.
The strongest objection is duplicated configuration
That last paragraph is the objection, and it is stronger than I have made it sound. Two app clients are two copies of token validity units, two ExplicitAuthFlows lists, two read-attribute sets, two callback lists. The failure mode of duplicated security configuration is not verbosity; it is silent one-directional drift. Nobody notices when one client's refresh token validity drifts off the thirty days both currently carry, because nothing in Cognito or CloudFormation compares the two clients. And the benefit I am claiming for the split is only worth something if something reads the distinction, which today nothing does: aud goes into jwtVerify as a membership test and is never consulted again by any handler in this codebase.
The second half of that is simply true, and it is the honest state of the system: I have preserved a distinction I am not currently using.
The defense is narrow. The distinction is cheap to preserve and expensive to recover, because recreating it later means issuing a new client, coordinating a client-ID change with an Android developer who is not on my team, re-authenticating every user on that platform, and accepting that every token issued before the split is permanently unattributable. Keeping it means writing a config value twice and reviewing both halves when one changes. I will take the drift risk over the archaeology.
The rule I take from this
When a second consumer appears in front of a token check, give it its own client identity even though nothing would break if you didn't. The audience claim is the only place the system records which consumer it was speaking to, and the moment two consumers share one value, that record is gone and no amount of logging downstream reconstructs it.
The boundary: this holds where consumers are enumerable and long-lived, meaning a handful of first-party apps you control and can re-register. It does not survive contact with a public API carrying thousands of registered clients, where the audience becomes a lookup rather than a list and you want resource-server audiences and scopes instead.
Four days after the split I wrote 97 lines of requirements for the Android developer, and one of its checkboxes reads "No client secret — Android is a public client, must use PKCE only", which is RFC 8252 sections 8.1 and 8.4 restated as a deliverable. That document is what the mobile game-mobile-prod would have cost. You cannot hand someone outside your team a per-platform contract once your own system has stopped believing the platforms are separate things.