Engineering
Google OAuth on Amplify Gen 2: One Account Per Email, a Custom Domain, and the Gotchas We Hit
Turning on Google for Cognito looks like a checkbox until you care about one Cognito sub per email and a consent screen that does not say amazoncognito.com. Here is the account-linking retry, the circular-dependency fix, the secret newline that broke Identity Pools, the Hub race, and the two-phase custom-domain cutover that kept production up.
Adding "Sign in with Google" to an Amplify Gen 2 app looks like the easiest auth win on the internet. Cognito already has a Google identity provider. Amplify's Authenticator already has a Google button. The docs show externalProviders.google and a couple of secrets. Merge the PR, ship it, go home.
That path is fine if you have never had users. It is a footgun if your product already identifies people by email and stores everything against a Cognito sub. Google happily creates a second Cognito user for the same email. Your returning customer lands in a blank account. Support tickets write themselves.
This is the playbook we wish we had before wiring Google into a live Cognito user pool on Amplify Gen 2: how to keep one account per email, how to absorb the intentional OAuth "error" that linking requires, how to not take production down while putting auth.yourdomain.com in front of managed login, and which assumptions look correct until CloudFormation, Amplify Hub, or Google's consent screen prove otherwise.
Core insight
Google is a sign-in method, not a second user directory. On Cognito that means a PreSignUp_ExternalProvider trigger that links (or creates) a native user by email, aborts the federated sign-up, and relies on the client to retry once. Separately: the consent screen names the Hosted UI host — if that host is *.amazoncognito.com, users think they are authorizing AWS. Fix the domain with a Cognito custom domain, not with wishful branding in the Amplify console.
What "Done" Actually Means
For a product that already has email/password users, Google is not done when the button works once. Done means:
- A user who already has
alice@example.com+ password and later clicks Google gets the same Cognitosuband the same orgs, teams, and assets. - A brand-new Google user still gets a stable native Cognito user (so they can later set a password via forgot-password without creating a twin).
- Every surface that completes Hosted UI OAuth — browser app and any OAuth facade you run for MCP / CLI clients — understands the one-time linking abort.
- Google's consent screen names your product domain, not a 40-character Cognito hostname.
If you only check "I landed in the app," you will miss the duplicate-user case until a paying customer hits it.
The Incorrect Assumption That Starts Most Bad Designs
Incorrect assumption: "Cognito will just attach Google to the existing user with the same email."
What actually happens by default: Cognito creates an EXTERNAL_PROVIDER user whose username looks like google_11544379…. Different sub. Same email attribute. Your app treats them as strangers.
There are console toggles and older "link users" stories floating around the internet. Do not bet a production migration on them. The pattern that is boring, documented in AWS samples, and works with Amplify Gen 2 triggers is:
- On
PreSignUp_ExternalProvider, find or create a native user for that email. - Call
AdminLinkProviderForUserso Google's subject points at that native username. - Throw a sentinel error so Cognito does not also create the orphan federated user.
- Have the client retry the OAuth redirect once; the second attempt signs into the linked native user.
Common failure
Shipping Google without PreSignUp linking "works" in QA if testers only use brand-new Gmail addresses. Production users with existing password accounts are the ones who discover the empty second identity. Test with an email that already owns data.
Amplify Gen 2 Wiring That Is Actually Required
In amplify/auth/resource.ts, enable Google under loginWith.externalProviders with secrets (not plaintext), scopes openid email profile, and attribute mapping for email / email_verified. Register the PreSignUp function under triggers.
Two URL ordering details bite people:
- Callback URLs: put your app origins (
https://example.com/) before any secondary OAuth relay callbacks (for us,/mcp/oauth/callback). Amplify'ssignInWithRedirectpicks the first configured callback that matches the current origin. Put the relay first and the Authenticator "succeeds" into the wrong path. - Logout URLs: every origin you serve the app from — including
www— must be listed, or Hosted UI logout rejects the redirect.
Hosted UI domain configuration is the other Gen 2 surprise. Enabling externalProviders already creates a UserPoolDomain with a generated prefix. In current @aws-amplify/backend versions, defineAuth does not accept domainPrefix the way older samples show. Override Amplify's generated domain in amplify/backend.ts instead of calling userPool.addDomain for a second prefix.
Gotcha: "a user pool supports one domain"
- True for two prefix domains. Adding a second prefix fails the whole deploy with a useless
Invalid request provided: AWS::Cognito::UserPoolDomain. - False for prefix + custom. Cognito allows one Cognito-owned prefix domain and one customer-owned custom domain on the same pool. That coexistence is what makes a safe cutover possible.
Account Linking: The Lambda, the Marker, and the Retry
The PreSignUp handler should ignore native sign-ups and only run on PreSignUp_ExternalProvider. Parse the Cognito username as <provider>_<subject>, list users by email, link to a confirmed native user (or create one with a random permanent password), then throw a stable marker string such as RULECMS_ACCOUNT_LINKED.
Cognito turns that throw into an OAuth error redirect:
/?error=invalid_request
&error_description=PreSignUp failed with error RULECMS_ACCOUNT_LINKED.That looks like a bug in the address bar. It is the expected first-time path. The client must detect the marker and call signInWithRedirect again — once.
Failed attempt: Hub-only failure handling
Our first frontend listened for Amplify Hub's signInWithRedirect_failure. Clean. Idiomatic. Dead on arrival.
Amplify.configure() in _app.tsx runs at module scope. It parses the OAuth callback — including errors — synchronously, before any React useEffect can subscribe to Hub. By the time the listener exists, the failure event has already fired into the void. The user sits on /?error_description=…RULECMS_ACCOUNT_LINKED… and never retries.
What works: on mount, read window.location.search yourself. If the description contains the marker and a sessionStorage flag says you have not retried yet, set the flag and call signInWithRedirect. Clear the flag on other outcomes. Separately, listen for Hub signInWithRedirect success to route into the app.
Common failure
Treating the linking abort as a user-visible error. Do not toast "invalid_request." Swallow it, retry once, and only surface a message if the second attempt also fails.
Failed attempt: naive username parsing
event.userName.indexOf('_') returns -1 when there is no underscore. JavaScript's slice(0, -1) then returns almost the whole string. Your "empty provider" guard never trips, and you fail later with a more confusing error. Require separatorIndex > 0 before slicing. Unit-test a username with no underscore — that test caught our bug immediately.
Unconfirmed natives are a hijack footgun
If someone starts email/password signup for victim@example.com and never confirms, a later Google sign-in for the real owner can get stuck behind that unconfirmed shell. Our handler deletes unconfirmed natives with the same email before creating the canonical confirmed user. Be explicit about that in code review; it is the right tradeoff for email-as-identity products, but it is a deliberate delete.
IAM: The Circular Dependency You Cannot "Just Reference"
The PreSignUp Lambda needs ListUsers, AdminLinkProviderForUser, AdminCreateUser, AdminSetUserPassword, and often AdminDeleteUser. The obvious Amplify Gen 2 move is:
resources: [backend.auth.resources.userPool.userPoolArn]That creates a CloudFormation circular dependency: the user pool depends on the trigger Lambda, and the Lambda's policy depends on the user pool. Deploy fails with CloudformationResourceCircularDependencyError.
What works: build the ARN from the Lambda stack's partition / region / account pseudo parameters and scope to userpool/*. The handler only ever operates on event.userPoolId from the pool that invoked it. Also put the function in resourceGroupName: 'auth' so it lands in the auth stack instead of surprising dependency edges.
Secrets: The Trailing Newline That Wastes an Afternoon
Amplify sandbox secrets are often set with shell redirects or printf. This looks harmless:
printf '%s\n' "$GOOGLE_CLIENT_ID" | npx ampx sandbox secret set GOOGLE_CLIENT_IDThe \\n becomes part of the secret. Cognito then fails Identity Pool / supported login provider validation with a constraint error about map value length ≤ 128. The client id in the console looks fine. The stored value is not.
Use printf '%s' (no newline) and verify length after set. When debugging "IdP misconfigured" errors after a secrets change, assume whitespace corruption before you assume Google Console misclicks.
If You Have a Second OAuth Surface, It Must Retry Too
We expose an MCP OAuth facade in front of the same Cognito app client. MCP clients never see Amplify Hub. If the facade forwards RULECMS_ACCOUNT_LINKED to Claude or Cursor, first-time Google through MCP is permanently broken even when the website works.
The authorize endpoint must stash enough state to rebuild the Cognito authorize URL — including PKCE code_challenge and scopes — inside the OAuth state (or a signed relay blob). The callback detects the marker, reissues authorize once, and only then returns a human-readable error. Loop-guard with a linkRetried flag in that state.
Inventory every completion path
- Authenticator /
signInWithRedirectin the SPA - Any server-side OAuth relay (MCP, mobile BFF, partner portals)
- Any custom callback page that is not the Amplify default
Each path needs the same marker helper. Duplicate the sentinel string into the Lambda bundle; Lambdas cannot import your Next.js isomorphic module unless you deliberately share a package.
The Consent Screen Still Said amazoncognito.com
Functional Google sign-in still looked untrustworthy: Google showed continue to <prefix>.auth.us-east-1.amazoncognito.com. Users do not know that host is "your" Cognito. They know it is not your product.
The fix is a Cognito custom domain (for us, auth.rulecms.com), not a logo upload in Amplify. Requirements that are easy to get wrong:
- ACM certificate must be in us-east-1, even if the user pool is elsewhere (ours happened to be us-east-1 already).
- Parent domain must already resolve (Cognito rejects custom domain create otherwise).
- DNS for the auth host must be a CNAME to Cognito's CloudFront alias, DNS-only. Orange-cloud / proxied Cloudflare terminates TLS with the wrong certificate and breaks the redirect.
Incremental cutover that does not break sign-in
Do not flip traffic in the same deploy that creates the domain. You cannot write the DNS record until CloudFormation returns the alias target.
- Phase A — additive. Set an env var with the ACM cert ARN. Deploy. Create
auth.example.comalongside the existing prefix domain. App and MCP keep using the prefix via a backend output that is still the old host. - Add Google redirect URI
https://auth.example.com/oauth2/idpresponsewithout removing the amazoncognito.com URI. - Create the Cloudflare CNAME to the alias target (grey cloud). Verify DNS, TLS
CN=auth.example.com, and that/oauth2/authorize302s to/loginon the custom host. - Phase B — flip. Set something like
AUTH_CUSTOM_DOMAIN_LIVE=true, redeploy, and publish the custom host as the authoritativeHOSTED_UI_DOMAINcustom output.
Keep a single resolver for that output. amplify_outputs.json's auth.oauth.domain continues to describe the prefix domain even after overrides. If the SPA trusts outputs and the MCP facade trusts a custom key, they will disagree the week you flip — and only one of them will look broken in QA.
What Google shows after the custom domain
With auth.example.com, Google collapses the label to the registrable domain and shows continue to example.com. That is already a huge trust upgrade. Showing your app name + logo is a separate Google verification path (Search Console ownership, privacy/terms URLs, review). Non-sensitive scopes do not require that verification for sign-in to work — only for the branded chrome.
The Process We Used So Production Stayed Boring
The technical pieces matter, but the order mattered more. This is the validation ladder that kept us from learning about linking bugs on the production pool:
- Sandbox first. Separate Google OAuth client (or at least separate redirect URIs). Set sandbox secrets. Deploy with
ampx sandbox. - Prove the button. Hosted UI opens, Google consents, something returns to localhost — even if linking is wrong.
- Prove linking with an existing email. Create a password user, add data, sign out, Google with the same email, confirm the same assets. Then try a never-seen email and confirm a native user was created.
- Prove the abort UX. Watch for the marker in the URL and confirm the silent retry; if you still see the error sticky in the address bar, you have the Hub race.
- Prove secondary surfaces. Hit the MCP (or other) authorize path with a first-time Google user.
- Production Google project + secrets. New client, both redirect URIs when custom domain is planned, Amplify console secrets on
main. - Ship linking + Google before the custom domain if you need the feature live. Ugly consent text beats no Google.
- Custom domain Phase A / Phase B as above. Verify TLS and authorize on the new host before flipping
HOSTED_UI_DOMAIN. - Incognito production test with the email that owns real data. Screenshot the consent screen. Confirm
/app.
Gotchas worth tattooing on the runbook
- Default Google ≠ one Cognito
subper email. PreSignUp link + abort + retry is the product requirement, not polish. - Hub
signInWithRedirect_failureloses the race toAmplify.configure. Read the query string on mount. - Policy ARN → user pool ARN while the pool triggers the Lambda = circular dependency. Use
userpool/*from pseudo parameters. - Trailing newline in
GOOGLE_CLIENT_IDproduces surreal Identity Pool validation errors.printf '%s'. - Do not add a second prefix domain. Override Amplify's generated one; add a custom domain separately.
auth.oauth.domainin amplify outputs lies after domain overrides. Publish and consume one authoritative Hosted UI domain output.- App callback URLs must precede relay callback URLs in
externalProviders.callbackUrls. - Cloudflare orange-cloud on the Cognito custom domain breaks TLS. Grey cloud only.
- ACM for Cognito custom domains is us-east-1. Always.
- Username parse: require
indexOf('_') > 0, not truthy slices. - Every OAuth completion surface needs the linking retry — website and facades.
- Keep the prefix domain after the custom domain goes live. Removing it only adds risk.
Branding: App Name and Logo on Google's Screen
After the custom domain, Google already shows your registrable domain. Getting RuleCMS (or your product name) plus a logo takes Google's brand / verification path:
- Verify the domain in Google Search Console (DNS TXT via your DNS host).
- Fill Google Auth Platform branding: app name, support email, logo, homepage, privacy policy, terms of service URLs.
- Submit verification if Google requires it for those assets. With only
openid email profile, sign-in itself works without this — branding is the reason to go through review. - Optionally skin Cognito Managed Login separately; that only affects the Cognito
/logininterstitial, not Google's consent chrome.
Do not block the functional launch on logo approval. Ship the custom domain, then queue branding as follow-up work with a dated TODO. Users trust example.com far more than they trust d123.cloudfront.net or amazoncognito.com; the logo is incremental.
Minimal Checklist Before You Call It Shipped
- Google IdP secrets set without trailing newlines; sandbox and production clients both have the right
/oauth2/idpresponseredirect URIs. - PreSignUp linking covered by unit tests (existing user, new user, unconfirmed cleanup, bad username).
- Browser retry reads the URL (not only Hub); MCP/facade retry has a loop guard.
- Manual test: existing password account + Google → same assets; brand-new Google email → account created and usable.
- Custom domain (if you care about consent trust): Phase A create + DNS, Phase B flip authoritative Hosted UI domain; TLS and authorize verified first.
- Consent screen screenshot stored in the PR or ops notes; branding TODO filed if logo/name still pending.
Google on Amplify Gen 2 is "easy" only when you ignore identity. Once you insist on one email → one Cognito sub, the real work is linking, retries, and honest domains. Do those on purpose, validate them in that order, and the Authenticator Google button finally becomes the product feature people thought it was on day one.