Sync Users Between Two WordPress Sites: What Actually Breaks

php dev.to

This started with a database that was getting fat.

We had a store. One WordPress install doing everything: the catalog, the marketing pages, the customers, the orders. And if you have run a WooCommerce site for more than a year you know exactly where this goes. wp_usermeta grows. Order meta grows. Sessions, carts, coupon usage, abandoned checkout junk from whatever plugin you installed and forgot about. The product side of the site — the part that has to be fast, the part Google actually crawls — was sharing tables with the part that processes orders.

So I made a call: split it. Products and content on the main domain, the transactional side on its own install, on a subdomain like shop.domain.com. Separate database, separate load, separate blast radius when something goes wrong.

Splitting the store was the easy part. It took an afternoon.

The users were the problem. Because the moment you have two installs, you have two wp_users tables, two sets of IDs, two password hashes, and a customer who created an account on one site and is now staring at a login form on the other one wondering why their password doesn't work.

Quick disclosure before I go further: I work on plugins at LumoWP, and one of them solves this. It shows up near the end of this post because that is where it belongs. We are not affiliated with Automattic or WooCommerce — everything below is our own experience running this setup, not an official position from anyone.

The first thing that breaks: the same person is two different users

Your instinct is that this is a small job. Hook user_register, POST the user to the other site, done. Something like this:

add_action( 'user_register', function ( $user_id ) {
    $user = get_userdata( $user_id );

    wp_remote_post( 'https://shop.example.com/wp-json/mysync/v1/user', [
        'body' => [
            'login' => $user->user_login,
            'email' => $user->user_email,
            'pass'  => $user->user_pass, // already a hash, do not re-hash it
        ],
    ] );
} );
Enter fullscreen mode Exit fullscreen mode

That is about forty minutes of work and it looks like it works. Register on site A, the user appears on site B. Ship it.

Then you notice the first crack. $user_id on site A is 4102. On site B, wp_insert_user() gave that same human being ID 87. Nothing in WordPress says those two rows are the same person. So every subsequent update — the customer changes their email, you change their role, they update their shipping address — needs a mapping table you now have to build and maintain yourself, keyed on something stable. Email is the obvious candidate until someone changes their email.

The second crack is that hash. You are moving a password hash over the wire. If that endpoint is not authenticated with a real shared key and running over TLS, you have built a credential leak with a REST route in front of it. This is the point where "small job" stops being true.

Copying usermeta does not copy the role

This one cost me an actual evening, and it is my favourite piece of WordPress trivia now.

A user's role is not a column. It is a row in wp_usermeta, and the meta key is prefixed with the table prefix of the site it belongs to:

SELECT meta_key FROM wp_usermeta
WHERE user_id = 4102 AND meta_key LIKE '%capabilities';

-- wp_capabilities      ← main site, prefix "wp_"
-- shop_capabilities    ← store install, prefix "shop_"
Enter fullscreen mode Exit fullscreen mode

In code it is $wpdb->prefix . 'capabilities', which is why the roles and capabilities API never makes you think about it — right up until you sync two installs with different prefixes.

So if you take the honest, obvious approach and copy every usermeta row across, the user arrives on the second site with a wp_capabilities row that the second site does not read, and therefore no role at all. They can log in. They can see nothing. Same for wp_user_level. Same for wp_dashboard_quick_press_last_post_id and every other prefixed key you just dragged along for no reason.

Roles have to be mapped, not copied. And once you accept that, you realize you actually want mapping anyway: a shop_manager on the store should probably be a plain subscriber on the marketing site. Blindly mirroring roles across installs is how an editor on one site quietly becomes an editor on a site they were never meant to touch.

One login, or the customer gives up

Syncing accounts gets you identical credentials on both sites. It does not get you a session.

You have seen how this is supposed to feel. You log into Gmail and you are logged into every Google property. Nobody asks you again. That is the bar now, and if your customer has to type their password a second time to move from your catalog to your checkout, a percentage of them just leave.

The tempting fix, if both sites are on the same root domain, is cookie sharing:

// wp-config.php, identical on both installs
define( 'AUTH_KEY',        '...same...' );
define( 'LOGGED_IN_KEY',   '...same...' );
define( 'LOGGED_IN_SALT',  '...same...' );
define( 'COOKIE_DOMAIN',   '.example.com' );
Enter fullscreen mode Exit fullscreen mode

Here is why that is not enough. The WordPress logged-in cookie is roughly username|expiration|token|hmac, and when a request comes in, the site validates that token against a session token stored in that install's own usermeta. Site B has never issued that session. It has no matching token. The cookie is presented, checked, and rejected.

And that is the optimistic case. If your two sites are on genuinely different domains rather than subdomains, the browser will not even send the cookie. There is nothing to reject.

Real cross-site login is a token handoff: site A signs a short-lived, single-use token, redirects the user to site B with it, site B verifies the signature against the shared key, matches the identity to its own local user, and calls wp_set_auth_cookie() for that local user ID. That is SSO. It is a protocol, not a config constant, and building it correctly — nonce, expiry, replay protection, clean failure when the user does not exist yet — is a real project.

WooCommerce addresses live in usermeta and they drift

Assume you got all of the above working. Accounts match, roles map, login carries over. You are still not done, because a WooCommerce customer is not just a login.

The saved billing and shipping address a returning customer expects to see pre-filled at checkout is stored as flat user meta:

$customer_fields = [
    'billing_first_name', 'billing_last_name', 'billing_company',
    'billing_address_1',  'billing_address_2', 'billing_city',
    'billing_state',      'billing_postcode',  'billing_country',
    'billing_phone',      'billing_email',
    'shipping_first_name', 'shipping_last_name', 'shipping_address_1',
    // ...and the rest of the shipping set
];
Enter fullscreen mode Exit fullscreen mode

HPOS moved orders into dedicated tables, but the customer's saved address did not move with them — it is still meta hanging off the user.

Which means if you only sync the account, your customer registers on the main site, gets bounced to the store to check out, and finds an empty address form. They fill it in there. Now the two sites disagree about where this person lives, and the next time they update it, they update one of them. The version you email an invoice from is a coin flip. This is the failure nobody catches in testing, because in testing you have three users and you made all of them.

The sync that fires on registration is the sync that times out

Last one, and it is the one that turns a working prototype into a 3 a.m. problem.

Every hook I have shown you fires inside the user's request. user_register runs while a real person is waiting on a spinner. So does profile_update. If you fire a blocking wp_remote_post() there and the other site is slow, your registration is slow. If the other site is down, your registration hangs until the HTTP timeout — and then the user is created locally and never syncs, silently, forever.

The correct shape is obvious once you have been burned:

add_action( 'profile_update', function ( $user_id ) {
    wp_schedule_single_event( time(), 'lumo_push_user', [ $user_id ] );
} );
Enter fullscreen mode Exit fullscreen mode

Except wp_cron only runs when someone visits the site, so on a low-traffic install your "immediate" sync can sit there for an hour. And a queue without retry with backoff is just a slower way to lose the same event. And a retry system without a log means you cannot answer the only question that matters when a customer complains: did this user actually sync, when, and what came back?

So the real list is: a queue, a scheduler you trust more than default cron, exponential backoff, a dead-letter case, a log you can read, and a way to force a full re-push when you inevitably discover a batch that never made it. That is not a snippet in functions.php. That is a plugin.

What we ended up building

We wrote it as a product, because we had rebuilt three-quarters of it on two separate client projects already. It is called Lumo User Sync, and the architecture is deliberately boring: one master site, as many sub-sites as your setup needs. Master is the source of truth. You connect a sub to the master with a generated key, and user data moves over the REST API, encrypted.

What it does, concretely:

  • Master-to-sub real-time sync for user accounts — creation, profile changes, password changes.
  • Role mapping per connection, so shop_manager on one site can land as subscriber on another. Per connection, not one global rule, because the store and the community site rarely want the same mapping.
  • SSO across every connected site. One login, and the session follows the user across domains and subdomains.
  • WooCommerce customer sync — name, company, address, city, state, postal code, country, phone and billing email, so checkout is pre-filled wherever the customer lands.
  • A background queue with automatic retry, instead of blocking the registration request.
  • Detailed sync logs, so "did this user sync?" is a question with an answer.
  • Bulk push from the master for the day you connect a new sub-site and need to backfill twelve thousand existing accounts.

It does not care whether your sites are on one domain, several subdomains, or completely unrelated domains. That was the point.

Honest caveats — who should not buy this

If you are on real WordPress Multisite, you probably do not need it. Multisite already shares one wp_users table across the whole network. Your users are, by definition, already synced. Our plugin is for people who deliberately run separate installs with separate databases — which is the whole reason I split that store in the first place, but it is not everyone's situation.

If you have exactly two sites and neither of them is WooCommerce, a custom REST endpoint and a couple of hooks might genuinely be enough. It is a weekend of work and you will own it. Just go in knowing about the prefix trap and the session token thing, because those are the two that will eat your weekend twice.

If your requirement is enterprise identity — logging WordPress in against Okta, Azure AD, or any SAML/OIDC identity provider — this is the wrong tool. We do WordPress-to-WordPress. Go get a dedicated SAML plugin.

And if your database is not actually hurting, do not split the site at all. Splitting a store across two installs is a real architectural decision with real ongoing cost, and syncing users is only the first bill. One well-indexed install with a cleanup routine beats two installs held together with a sync layer, every time. Split when the pain is measurable, not because it sounds cleaner.

Where it earns its place is the setup I described at the top: separate installs on purpose, shared customers, and a login experience that has to feel like one company instead of three.

If that is your situation, the plugin is Lumo User Sync. Watch the demo video on the product page before anything else — the master/sub connection flow is much easier to judge in ninety seconds of video than in any amount of feature list, including the one I just wrote.


If you have run a split WordPress setup and hit something I did not list here, I want to hear it in the comments. Half the retry logic in this plugin exists because of a failure mode we did not predict.

Source: dev.to

arrow_back Back to Tutorials