DEV Community

Cover image for Emails in the user's language, and the frozen Cognito pool
Ibukun Demehin
Ibukun Demehin

Posted on Originally published at ibukundemehin.com

Emails in the user's language, and the frozen Cognito pool

After Part 33, a user could pick Arabic in onboarding and get an app that flipped right-to-left within a restart — and then receive a verification email in English. The emails hadn't kept up, and the reason was structural: the two email senders fire in places where no profile row exists. The sign-up verification trigger runs before the user's profile is created. The forgot-password trigger runs for someone signed out. Neither can read UserProfile.locale.

The fix was two lines on the client and one attribute in Cognito — and then a deploy that failed on every branch for reasons the sandbox was structurally unable to show me.

TL;DR — Cognito's standard locale attribute is a mirror of the app's language for the one consumer that can't reach a profile: the app writes it at sign-up (the language the sign-up screen is rendered in — so the verification email matches by construction) and on every language change. The trigger reads it, treats it strictly as a whitelist key (user-controlled text is never echoed into HTML), falls back to English, and flips Arabic to dir="rtl". Deletion emails stay English by design — a per-address language lookup would turn the no-oracle guest form into an oracle. The war story: declaring locale in defineAuth.userAttributes failed every branch deploy with "Invalid AttributeDataType input", because a user pool's attribute schema is frozen at creation — and the sandbox looked fine only because its pool was born after the change.

(Part 34 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)

The attribute is a mirror, not a source of truth

UserProfile.locale stays the app's real language setting. The Cognito locale attribute exists for exactly one reason: the customMessage trigger from Part 28 receives event.request.userAttributes inline — no lookup, no latency, and available pre-confirmation when nothing else about the user exists yet.

The write side is three touch points, all client-side:

  1. Sign-up passes locale as a user attribute — the app's resolved UI language at that moment ("es", never the "auto" sentinel). Which is the language the person is currently reading the sign-up screen in — so the verification email matches by construction, without a round trip.
  2. Every explicit language change (onboarding, the More → Language picker) fires a best-effort attribute update alongside the profile write.
  3. Nothing on sign-in — the attribute rides along from whenever it was last set.

The two lines at sign-up had been added in Part 33 "for later." This was later.

The read side: whitelist, never echo

The trigger localises verification and reset emails from a typed translations module — and the one rule that matters for security: the attribute is treated strictly as a whitelist key. It's user-controlled text; the client can write anything into it. So it's looked up against the supported-language map and never echoed into the HTML. An unknown or hostile value falls back to English silently. The verification included rendering the real handler with Spanish, Arabic, an unsupported locale, and a deliberately hostile string, and screenshotting every output.

Arabic flips the email shell to dir="rtl" — accent borders and card icons hug the reading edge — while the code digits stay LTR, because a six-digit code is not prose. The admin-invite email stays English: the console is English-only, and the invite goes to an admin.

The email that stays English on purpose

Deletion-request emails from the public web form are English by design, and the reason is a small security argument. A guest form has no trustworthy language signal — the browser locale is trivially spoofable and says nothing about the account. The tempting alternative — look up the address's stored language and reply in it — would make the request mutation behave differently for known addresses than unknown ones. That's the email oracle Part 32 spent a section preventing, reintroduced through translation. A language leak is still a leak; English for everyone is the fix.

The extended tier came free

When the thirteen extended-tier languages landed, the email templates followed — as a Lambda-only change: existing users with a stored locale started receiving translated emails on deploy, with no app update at all. The email's button labels and screen names are taken from the app's own locale files, so "Speak" is the same word in the welcome email as on the Home screen. One vocabulary, two renderers — a rule this project keeps rediscovering.

The war story: a schema that can't change

Somewhere along the way — because it looked like the correct, declarative thing to do — locale got declared in defineAuth's userAttributes. The sandbox deployed fine. The app worked. Then every dev-branch deploy failed, build after build, with:

Invalid AttributeDataType input
Enter fullscreen mode Exit fullscreen mode

The cause is a Cognito fact worth memorising: a user pool's attribute schema is frozen at creation. The update API has no schema parameter at all. When CloudFormation is asked to change the schema of an existing pool, its handler falls back to the one thing it can call — add-custom-attributes — which rejects a standard attribute name (locale is OIDC-standard, not custom) with that error. Every branch deploy tried, every branch deploy failed.

And the sandbox? Its pool had been created after the declaration was added, so locale was in the creation-time schema and there was nothing to update. The sandbox wasn't lying; it was structurally incapable of reproducing the failure. "It works in the sandbox" and "it deploys to a branch" are different claims when the resource in question can only be shaped once.

The twist that made it worse: the declaration was also a no-op. Every Cognito pool is born with the full set of standard OIDC attributes — locale included, mutable and optional — and an app client with no explicit read/write attribute lists can already use all of them. The line had done nothing except break deploys.

The fix was deletion: remove the declaration. Schema removals are ignored on update — the documented-safe direction — so the sandbox pool keeps its harmless drift and the branches deploy again. The rule now lives in the repo's own instructions: never declare a standard attribute; they already exist.

What I took away

  • Mirror, don't move. The profile stays the source of truth; the auth attribute exists for the one reader that can't reach it.
  • Write the language the user is reading. The sign-up screen's own locale is the correct email language, by construction.
  • User-controlled attributes are keys, never content. Whitelist, fall back, never echo.
  • Translation can be an oracle. If a response changes based on whether an address is known, the language of the response counts.
  • Some resources are shaped once. A user pool's schema is frozen at creation — and a young sandbox will never show you that.
  • Sandbox age is a variable. Recreate it occasionally, or at least know which resources it created after the code changed.

Next up

Part 35: the only channel to users after a build has shipped — in-app announcements with version targeting against what people are actually running, severity that picks the shape, and a force-update wall that fails open by construction.

What's your platform's "frozen at creation" resource — the one where the sandbox says yes and production says never?

Top comments (0)