Skip to content

Fix AI generator callback URLs becoming stale after site URL change #23127#23128

Merged
thijsoo merged 6 commits intoYoast:trunkfrom
chrisdavidmiles:fix/ai-generator-stale-callback-urls-after-domain-change
Apr 9, 2026
Merged

Fix AI generator callback URLs becoming stale after site URL change #23127#23128
thijsoo merged 6 commits intoYoast:trunkfrom
chrisdavidmiles:fix/ai-generator-stale-callback-urls-after-domain-change

Conversation

@chrisdavidmiles
Copy link
Copy Markdown
Contributor

Context

When a WordPress site is provisioned at a temporary/staging URL (common with Bluehost, Kinsta, WP Engine, and many other managed hosts) and the user later points a real domain to the site, the AI Generator's "Generate with AI" feature fails silently. The callback_url and refresh_callback_url are sent to Yoast's API only during token_request(), but token_refresh() never re-sends them. The API continues using the original (stale) staging URLs for all subsequent token delivery callbacks, which fail due to DNS/TLS mismatches.

This is not host-specific — any site that changes its URL after first using the AI Generator is affected. Yoast SEO does hook into update_option_home (via Indexable_HomeUrl_Watcher), but this only fires when the home option is updated through WordPress's update_option() API. If hosting providers change site URLs using wp search-replace or direct MySQL updates, those hooks do not fire, so Yoast never detects the change.

See #23127 for full reproduction steps and technical analysis.

AI Generator error showing callback to stale staging URL eqo.cfk.mybluehost.me failing with TLS certificate mismatch

Summary

This PR can be summarized in the following changelog entry:

  • Fixes a bug where the AI Generator's "Generate with AI" feature failed after a site's domain was changed, because stale callback URLs remained registered with the Yoast API from the original domain.

Relevant technical choices:

  • Self-healing detection via hash comparison: On each call to get_or_request_access_token(), the current callback URL is compared (via md5 hash) against a stored hash from the last successful token_request(). If they differ, stale tokens are deleted and a fresh token_request() re-registers the correct URLs.
  • md5 hash instead of storing the URL directly: wp search-replace would update a stored URL in lockstep with the site URL, masking the change. A hash is immune to this because search-replace won't find the old domain string inside a hex digest.
  • First run after upgrade forces re-request: When no stored hash exists (first use after upgrading to this version), have_callback_urls_changed() returns true, forcing a one-time fresh token_request(). This is an intentional tradeoff — it ensures all existing affected sites self-heal on their next "Generate with AI" click without any manual intervention (no SQL, no CLI, no user action). The cost is one extra token_request() per user on their first use after the upgrade. Subsequent uses are unaffected because the hash is stored after the first successful request.
  • Standalone WordPress option (get_option/update_option) instead of Options_Helper via DI: The compiled DI container (src/generated/container.php) hardcodes the Token_Manager constructor signature. Adding a new constructor parameter would require recompiling the container. Using get_option/update_option directly avoids this and keeps the change minimal. The Yoast team may prefer to refactor this to use Options_Helper with a recompiled container in a follow-up — the runtime behavior is identical either way.

Test instructions

Test instructions for the acceptance test before the PR gets merged

This PR can be acceptance tested by following these steps:

  1. Set up a WordPress site at a temporary URL (or use an existing site and note its current URL)
  2. Install and activate Yoast SEO and Yoast SEO Premium
  3. Create a post, add a focus keyphrase, and click "Generate with AI" — confirm it works
  4. Change the site URL (via Settings > General, wp option update home, or wp search-replace)
  5. Edit a post and click "Generate with AI" again
  6. Expected: The feature should work — the plugin detects the URL change, clears stale tokens, and re-registers with the correct callback URLs
  7. Click "Generate with AI" a third time to confirm subsequent requests also work without re-requesting tokens

To verify the self-healing on upgrade (simulating an existing affected site):

  1. On a site where "Generate with AI" is currently failing due to a prior domain change
  2. Upload and activate this patched version of the plugin
  3. Click "Generate with AI"
  4. Expected: It works on the first click — no manual database cleanup needed

Relevant test scenarios

  • Changes should be tested with the browser console open
  • Changes should be tested on different posts/pages/taxonomies/custom post types/custom taxonomies
  • Changes should be tested on different editors (Default Block/Gutenberg/Classic/Elementor/other)
  • Changes should be tested on different browsers
  • Changes should be tested on multisite

Test instructions for QA when the code is in the RC

  • QA should use the same steps as above.

Impact check

This PR affects the following parts of the plugin, which may require extra testing:

  • AI Generator token authorization flowToken_Manager::get_or_request_access_token() now checks for URL changes before proceeding. This is the entry point for all AI Generator features (suggestions, usage).
  • First use after upgrade — Every user's first "Generate with AI" click after upgrading will trigger a fresh token_request() regardless of whether their URL actually changed. This is by design to catch existing stale sites but should be verified to not cause issues at scale.

Other environments

  • This PR also affects Shopify. I have added a changelog entry starting with [shopify-seo], added test instructions for Shopify and attached the Shopify label to this PR.
  • This PR also affects Yoast SEO for Google Docs. I have added a changelog entry starting with [yoast-doc-extension], added test instructions for Yoast SEO for Google Docs and attached the Google Docs Add-on label to this PR.

Documentation

  • I have written documentation for this change. For example, comments in the Relevant technical choices, comments in the code, documentation on Confluence / shared Google Drive / Yoast developer portal, or other.

Quality assurance

  • I have tested this code to the best of my abilities.
  • During testing, I had activated all plugins that Yoast SEO provides integrations for.
  • I have added unit tests to verify the code works as intended.
  • If any part of the code is behind a feature flag, my test instructions also cover cases where the feature flag is switched off.
  • I have written this PR in accordance with my team's definition of done.
  • I have checked that the base branch is correctly set.
  • I have run grunt build:images and commited the results, if my PR introduces new images or SVGs.

Innovation

  • No innovation project is applicable for this PR.
  • This PR falls under an innovation project. I have attached the innovation label.
  • I have added my hours to the WBSO document.

Fixes #23127

…23127)

When a site's URL changes after AI generator tokens are initially
requested (e.g., migrating from a staging URL to a production domain),
the callback URLs registered with Yoast's API become permanently stale
because token_refresh() never re-sends them.

This adds self-healing URL mismatch detection: on each token retrieval,
compare an md5 hash of the current callback URL against a stored hash
from the last token_request(). If they differ (or no hash exists yet),
stale tokens are deleted and a fresh token_request() re-registers the
correct callback URLs.

Uses an md5 hash rather than storing the URL directly so the value is
immune to wp search-replace operations that would otherwise update the
stored URL in lockstep with the site URL, masking the change.
@enricobattocchi enricobattocchi added the changelog: bugfix Needs to be included in the 'Bugfixes' category in the changelog label Apr 7, 2026
@enricobattocchi
Copy link
Copy Markdown
Member

Hey @chrisdavidmiles! Thanks for the PR, sounds like a reasonable solution!
I see the unit tests are failing, as if they were built maybe for a previous iteration? can you give them a look?

- Use Brain\Monkey stubs() for update_option in setUp to avoid enforcing
  call counts on tests that never trigger token_request()
- Add default get_refresh_callback_url mock in setUp
- Remove get_callback_url/get_refresh_callback_url overrides from
  Get_Or_Request_Access_Token_Test that conflicted with the setUp hash
  default (different URL caused have_callback_urls_changed() to return
  true unexpectedly, and once() constraint broke with the additional call)
- Replace expect('update_option') with when()->alias() capture pattern
  in Callback_Url_Change_Test since stubs and expect are incompatible
  for the same function in Brain\Monkey
- Remove unused $callback_hash variable to fix CS warning threshold

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@chrisdavidmiles
Copy link
Copy Markdown
Contributor Author

@enricobattocchi Tests updated.

@enricobattocchi
Copy link
Copy Markdown
Member

Thanks @chrisdavidmiles! Just one question: in case there are 2+ users using AI generate, I think the fix would unblock only the first one... But maybe it's a good enough trade-off for a real-world case where just one user is working on a staging env before migration?

Fixes multi-user scenario where only the first user to trigger the URL
change detection would re-register callback URLs. Now each user
independently detects the change via their own user meta hash and
re-registers on their next "Generate with AI" click.

Also improves multisite: since wp_usermeta is network-wide, users
switching between sites automatically detect the different REST API
endpoints and re-register with the correct callback URLs.
@chrisdavidmiles
Copy link
Copy Markdown
Contributor Author

Thank you for catching that @enricobattocchi . Please review this new version that stores the hash per-user in user meta. Trying to think through more edge cases in advance, this new approach should also help multisite network sites (wp_usermeta is network-wide).

@chrisdavidmiles
Copy link
Copy Markdown
Contributor Author

@enricobattocchi I noticed src/ai-authorization/application/token-manager.php has a parallel copy of the same class under the old AI_Authorization namespace, and it doesn't have this fix. Is that still active at runtime, or is the deprecation branch (632-deprecate-the-old-code-in-free) expected to land first? If it's still live I'll need to apply the fix there too.

@thijsoo
Copy link
Copy Markdown
Contributor

thijsoo commented Apr 8, 2026

Hi @chrisdavidmiles The user based changes look good! and we do still rely on this double code for now. So could you also apply your change there? Than I will review it one more time and merge it!

Same fix as src/ai/authorization/ — both copies are active in the
compiled DI container and register identical REST routes.
@chrisdavidmiles
Copy link
Copy Markdown
Contributor Author

@thijsoo Updated, please review.

@thijsoo
Copy link
Copy Markdown
Contributor

thijsoo commented Apr 8, 2026

Thank you! I will start the acceptance test tomorrow morning and than hopefully merge it tomorrow as well!

Copy link
Copy Markdown
Contributor

@thijsoo thijsoo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CR looks good. I like the user specific meta solution. I checked the solution with only free and also with premium.

@thijsoo thijsoo added this to the 27.5 milestone Apr 9, 2026
@thijsoo thijsoo merged commit 6f0d97b into Yoast:trunk Apr 9, 2026
52 of 59 checks passed
@chrisdavidmiles chrisdavidmiles deleted the fix/ai-generator-stale-callback-urls-after-domain-change branch April 9, 2026 15:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog: bugfix Needs to be included in the 'Bugfixes' category in the changelog community-patch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: AI Generator "Generate with AI" fails after site domain change

4 participants