The Openstone website and Hongzhai Cloud ERP are two independently deployed sites, each with its own account system. We deliberately did not merge the user databases: the two differ in user boundary, operations cadence and security blast radius, and a merge makes "change one thing, both sites wobble" a permanent condition. Users, however, should not be blocked by that boundary — here is how one short-lived ticket connects the two at the session layer.
Why the session layer
Three more "convenient" options, ruled out first:
- Merging the user databases: touches account logic on both sides and couples two teams' release cadences — no;
- Sharing session cookies: two domains, two session systems; sharing cookies welds both sides' session security together — no;
- Copying password hashes: the password systems would have to track each other's upgrades and rotations, blurring responsibility — no.
What remains is credential exchange: after signing in on one side, a short-lived, single-use credential the other side can verify is exchanged for a session there. The connection narrows to one well-defined boundary.
The ticket
The ticket is an RS256-signed JWT valid for 120 seconds, each claim with a stated job:
| Claim | Value | Role |
|---|---|---|
iss |
Issuer identity | The consumer checks the source |
aud |
Consumer identity | Stops reuse against other services |
email |
User's email | The sole account-matching key |
jti |
Random unique id | Single-use replay protection |
iat / exp |
Issued/expiry times | The 120-second window |
The private key stays with the issuer; the consumer gets only the public key — the two sites exchange public material and nothing secret.
The journey of one ticket
- The user, signed in on the parent site, clicks "go to console";
- The issuer verifies the session → signs the ticket → returns a redirect carrying it;
- The browser navigates; the ticket appears in the consumer's URL;
- The consumer verifies the signature → checks issuer, audience, expiry → writes the
jtiinto a single-use registry (unique constraint); - It looks up an account by
email: found → the consumer's own standard login flow establishes a session; not found → "this email has no account yet" plus a registration link, never a silent account creation; - The user lands in the console with a fresh session.
Security-properties table
| Attack surface | Countermeasure | Verification |
|---|---|---|
| Replaying the ticket | jti unique constraint; a second submission must collide |
Measured: second consume → "already used" |
| Using an expired ticket | 120-second exp, checked at verification |
Measured: expired → ticket_expired |
| Forgery/tampering | RS256, public key on the consumer only | Measured: bad ticket → ticket_invalid |
| Cross-service reuse | aud pinned to the consumer |
Asserted in decode options |
| Accidental account creation | No match ⇒ prompt only, never create | Measured: unknown email → prompt + register link |
Three traps
1. The ticket was consumed twice
During integration the console stalled on an error: "ticket already used". Server logs showed the same ticket submitted twice within milliseconds — the first issued a session, the second hit the unique constraint.
The culprit was frontend: consumption lived in a page effect, and effects re-run when dependencies change (once language resources loaded, the translation function changed identity and triggered a second run). Two fixes:
- A single-flight guard: a ref ensuring one execution per mount;
- Decoupling liveness from the dependency array: move "is the component still mounted" out of the effect cleanup so it only flips on a real unmount — otherwise the first request's success response gets discarded when deps change, leaving the page on "signing you in…" forever.
Frontend logic around single-use credentials does not fit the "re-runnable effect" model; executions must be constrained explicitly.
2. The page lived in the wrong route tree
Early on the page returned 404. The product site has a rewrite rule sending locale-prefixed /auth/* paths to the root-level auth tree, where all app pages live. A page placed "intuitively" under the locale directory can never match.
Lesson: when adding a page to someone else's framework, find where sibling pages actually live before writing the file.
3. The key file was unreadable — silently
The ticket endpoint answered "not configured". The key file was mode 0600 and the container process ran as another user; a catch-all "unreadable means empty" branch swallowed the error, and the server claimed it had no configuration at all.
The fix: align key permissions with the container user (now a deploy-checklist line), report "missing configuration" as an explicit 503, and let a startup self-check surface it early. Silent degradation disguises configuration errors as business errors.
Takeaways
Connecting two account systems needs neither merged data nor shared credentials. Narrowed to "a short-lived one-time ticket + email matching + each side's standard login", it stays auditable and reversible; clear replay, routing and permission traps first, and the path is actually deliverable.
