A common shape in client work: there is an existing WordPress site doing something useful, usually a learning platform, and a new custom application that should feel like part of the same product.
The client does not want two logins. Nobody wants two logins.
Replacing WordPress is usually off the table, because it is already paid for and already full of content. So you bridge instead.
Decide who owns identity first
This is the whole design, and it is worth being deliberate about.
One system must be the source of truth for who exists and what they may do. Everything else consumes that. If both systems can create users, you will spend the rest of the project reconciling them.
For the music school application I built, the custom app owns identity. It knows who is enrolled, which family they belong to, and what they are paying for. WordPress and LearnDash consume that and handle course delivery.
The opposite choice is defensible when WordPress is the main product and the custom app is an add-on. What is not defensible is not choosing.
The flow
Once identity ownership is settled, the mechanism is straightforward:
- The user signs in to the custom app as normal.
- They click through to course material.
- The app mints a short-lived, single-use signed token carrying the user’s identifier and an expiry.
- The browser is sent to a WordPress endpoint with that token.
- WordPress verifies the signature, looks up or provisions the matching user, starts a session, and redirects to the course.
The token is not a session. It is a one-time introduction, valid for seconds.
Minting the token
import jwt, uuid
from datetime import datetime, timedelta, timezone
def make_sso_token(user: User) -> str:
now = datetime.now(timezone.utc)
return jwt.encode(
{
"sub": str(user.id),
"email": user.email,
"roles": user.lms_roles,
"iss": "https://app.example.com",
"aud": "https://learn.example.com",
"iat": now,
"exp": now + timedelta(seconds=60),
"jti": str(uuid.uuid4()),
},
SSO_SIGNING_KEY,
algorithm="RS256",
)
Five details that matter:
- Sixty seconds. This token only has to survive a redirect. A long expiry turns a leaked URL into an account takeover.
jti. A unique id so the receiving side can enforce single use.audandiss. Verified on the other end, so a token minted for one service cannot be replayed against another.- RS256, not HS256. Asymmetric signing means WordPress only needs the public key. A compromised WordPress install cannot mint tokens.
- Roles, not permissions. Send what the user is. Let the LMS decide what that means there.
Verifying on the WordPress side
In a small plugin, not in the theme, so a theme update cannot remove your authentication:
$decoded = JWT::decode( $token, new Key( $public_key, 'RS256' ) );
if ( $decoded->aud !== 'https://learn.example.com' ) { wp_die( 'Bad audience' ); }
if ( $decoded->iss !== 'https://app.example.com' ) { wp_die( 'Bad issuer' ); }
if ( get_transient( 'sso_jti_' . $decoded->jti ) ) { wp_die( 'Token reused' ); }
set_transient( 'sso_jti_' . $decoded->jti, 1, 300 );
$user = get_user_by( 'email', $decoded->email ) ?: wp_insert_user( [ ... ] );
wp_set_auth_cookie( $user->ID, false );
wp_safe_redirect( $target );
exit;
The jti transient is the single-use check. Store it for slightly longer than
the token lifetime, and a replayed token is rejected rather than honoured.
Note wp_safe_redirect rather than wp_redirect. It restricts redirects to
your own host, which closes an open-redirect hole that is otherwise easy to
leave open on exactly this kind of endpoint.
Deprovisioning is the part people forget
Single sign-on gets built, demonstrated, and signed off, and then a student leaves.
If deactivating them in the app does not do anything in the LMS, they keep their course access indefinitely. Decide up front what happens on deactivation: revoke, downgrade, or suspend. Then build it at the same time as the sign-in flow, because it will not get built later.
Mistakes worth avoiding
- Passing the user id in a plain query string. Signed, expiring, single-use, or it is not authentication.
- Long-lived tokens. Seconds, not hours.
- HS256 with a shared secret. WordPress plugins get compromised. Do not put your minting key on that side of the bridge.
- Provisioning silently with elevated roles. Auto-created users should get the least privilege that works.
- Letting both systems create accounts. Back to the first decision.
Why it is worth the effort
This was the fiddliest part of that project and the part the client noticed most. Not because they understood the token flow, but because their students clicked one link and were simply already logged in.
That is usually how infrastructure work lands. Nobody praises it when it works. They just stop mentioning the thing that used to be annoying.