Building a Cryptographic Identity Layer for the Monesize Product Ecosystem
When I started building this, Monesize was in the middle of a quiet transformation. It was no longer one product. It was becoming a product ecosystem: Core, the operational financial backend; the Gateway, a centralized trust and compliance layer; and Desk, a customer support product that would sit alongside them. Each one was an independent deployment. Each one had its own databases, its own sessions, its own authorization models, its own frontend, its own lifecycles.
Almost immediately I ran into the question that would shape everything after it. A user of Core is not automatically a user of Desk. The same person exists inside multiple products. So how does Desk know that a request claiming to be David is actually David? How does Desk trust the identity that Core has already established? And more specifically, how do I make that work when no two products share a database, and when the products are owned and deployed by different tenants?
The interesting engineering question becomes this: how do independently deployed Monesize products establish trusted user and tenant identity without sharing authentication secrets with one another?
| Monesize Identity Layer Architecture |
That question is the entire point of this post. I'm going to walk through the architecture I settled on, why I rejected the simpler alternatives, what I actually built as the founding engineer implementing this, and why the last point on the list is the one that makes all of it worth it.
The Naive Approaches, and Why They Fail
Before I describe what I built, I want to describe what I refused to build, because the refusals are the design.
Shared secrets between every product
The most obvious approach is to give every product the same secret. Core signs a token with it, Desk verifies with the same secret, done. This is the symmetric approach, and it's tempting because it's easy.
But it has a fatal flaw. The moment two products share a secret, both products can forge tokens for each other. Desk, which is supposed to be just a consumer of identity, suddenly becomes a producer of identity. If Desk can sign a token, then Desk can issue a token for any user in any tenant. The whole chain of trust collapses, because there is no verifier left anywhere in the system. Everyone can mint, so nobody can vouch.
That is not a theoretical worry. If a single product in the ecosystem is compromised, and it holds the shared secret, then that one compromise becomes a compromise of every product and every user in the ecosystem. The blast radius is the whole platform.
Trusting claims supplied directly by clients
The other tempting approach is to stop authenticating centrally at all. Let Core establish identity, then let clients pass Core's claims around in a header, and let downstream products trust whatever arrives.
This is how a lot of internal systems work in practice, and it's terrifying. Any client can fabricate a header. Any client can claim to be another user. When a product trusts claims supplied directly by clients, the product isn't doing identity verification at all. It's doing wishful thinking with a database.
I did not want Desk to behave like that. I did not want any product in the ecosystem to behave like that.
What I Actually Needed
I wrote down the real requirements, and they forced the design.
The first requirement is that each product authenticates its own users. Core knows how to authenticate Core users because Core owns the user records, the password hashes, the sessions, the MFA, the SSO. Desk should not need to know how that works, and should not replicate it. Desk has its own users and its own auth, but it also needs to recognize users who arrive from Core.
The second requirement is that identity must be provable, not claimable. When Desk sees a request that says "I am David from Core," that has to rest on cryptography, not on an unchecked header.
The third requirement is that no product ever hands its authentication secrets to another product. This is the constraint that kills the shared secret approach. If two products never share a secret, then neither product can impersonate the other, and the compromise of one product cannot spread to any other.
The fourth requirement is that each product keeps its own sessions, its own database, and its own authorization model. I was not trying to create one giant monolith with one global user table. I was deliberately keeping products independent, deployable on their own, evolvable on their own.
And the fifth requirement, the one I keep coming back to, is that adding a new Monesize product must not require redesigning the identity architecture. If there are two products and a third one is coming, I shouldn't have to invent a new identity flow for it. The third product should plug into the same mechanism the second one used.
Once those five requirements were on the table, the answer stopped being a policy decision and became a cryptographic one.
The Shape of the Solution
I ended up with an identity flow I described in my own project notes as "tenant signs, gateway verifies." That phrase, once I had it, did most of the architectural talking.
It works like this. A product authenticates its own users first, using its own mechanisms. Core logs a user in, issues its own session, and knows confidently who that user is.
When that user needs to move across the ecosystem, the trusted identity authority signs an identity assertion. In this design the issuing product is the authority. Core is the authority over its own users, and Core signs a short-lived token that says: this is user 546165, this is their email, this is their role, this is their tenant.
Tenant-specific public keys are registered with the gateway. Before any of this works, each tenant registers its public key with the gateway, which stores that key on the tenant record, keyed by the tenant's slug.
Private signing keys never leave the issuing product. This is the heart of the whole thing. The private key stays inside Core's environment, present only where Core runs. It never crosses the network, it's never sent to the gateway, it's never shared with Desk, it's never transmitted anywhere.
The gateway verifies signatures cryptographically. When a downstream product needs to confirm an identity, it sends the token to the gateway. The gateway looks up the tenant by the issuer claim in the token, finds that tenant's registered public key, and verifies the token's signature against it. The signature either checks out or it doesn't. There is no policy interpretation happening here. It's pure public key cryptography.
Downstream products consume verified identity, rather than trusting claims supplied directly by clients. Desk never sees the client's claims first. Desk hands the token to the gateway, the gateway returns the verified identity, and only then does Desk act on it. The claims Desk consumes are the claims the gateway cryptographically vouched for.
Why Asymmetric Keys, and Why I Had to Defend It
I want to be honest about one part of this, because it's the part where I nearly caved.
When I first described this to a colleague reviewing the design, the immediate reaction was "why do we need key pairs? I just want a key string." They preferred a shared secret, because it was one value, one environment variable, one mental model, and it felt simpler.
I get it. Shared secrets are easier to imagine. You paste the same string into two places and you're done.
But the whole security property dies with the shared secret. If the gateway holds the same secret that Core uses to sign, the gateway can mint tokens too. Once the verifier can mint, it's no longer a verifier. It's an attacker's dream. Anyone who compromises the gateway, or anyone who reads the gateway's environment, can sign an identity for any user in any tenant, and Desk will consume it because Desk trusts the gateway. The outcome is that a single leaked shared secret becomes a forged identity for every user in the entire ecosystem.
The asymmetric arrangement removes that possibility completely. The gateway holds only the public half of the key. With a public key you can verify a signature, and you can never create a new signature that would pass verification. The gateway can judge whether a token is genuine, and it's structurally incapable of producing a token that would fool anyone. That's not a policy, it's a mathematical fact. That was the difference I needed my colleague, and eventually everyone reviewing the design, to actually feel rather than just agree to on paper. The gateway stays. The public keys are all it needs.
What's Inside the Token
The identity token is a standard JWT, signed with RS256. It carries the claims downstream products actually need, and nothing that would let a product do harm.
The iss claim is the tenant slug, and it's the gateway's lookup key. When the gateway receives a token, it reads the iss claim, finds the tenant whose slug matches, and loads that tenant's public key. A token from the monesize-limited deployment, for instance, carries iss set to monesize-limited.
The coreUserRef claim is the user identifier inside the issuing product. It stays stable so downstream products can store and reconcile it over time. The email claim is the user's email, and the name claim is the user's display name, letting downstream products greet and contact the user without a second lookup.
The tenantRef claim ties the token to the right Desk tenant, and is how Desk knows which of its own tenants the user belongs to. It's a reference, not a data dump. The orgName claim carries the tenant's display name, and branch references, branch names, and role fields are included when they exist, so a downstream product can show useful context without reaching back into Core's database.
The token also carries an issued-at time and an expiry, defaulting to one hour. That short lifetime matters more than people expect, because it means a leaked token is only useful for a short window, and because it means the signing product can be confident about the freshness of what it's asserting.
I'm deliberately skipping any claims that would leak data a downstream product has no business knowing. A support product doesn't need payroll figures. The token asserts identity facts, not business data. That separation keeps the exposure surface small even in a worst-case leak.
Key Generation and Registration, in Practice
For a new tenant the operational flow is short, and I can walk through it because I ran it myself, end to end, more than once.
First you generate a key pair, one RSA key pair, two files. Then the private key goes into the tenant's Core environment, in this setup the IDENTITY_PRIVATE_KEY environment variable in the Core deployment. It lives in the environment file, is never committed to the repository, and never leaves the machine where Core runs.
Then you ask Core for its public key. I built a small endpoint that derives the public half from the configured private key, so you never have to keep the two halves in sync by hand. You hit the endpoint and you get a PEM block back.
Finally you register that public key with the gateway. The gateway has a tenant registration endpoint. You send the tenant slug, the tenant name, the app base URL, and the identityPublicKey. The gateway upserts the tenant record and stores the public key on it.
That's the entire setup. There's no key exchange ceremony, no shared vault, no secret distribution. The only artifact that moves between the systems is the public key, which is safe to move by design.
The Three Products, and Which Credentials Go Where
As I wired this across the real system, a useful pattern emerged, and it's worth stating plainly because it resolves a lot of confusion about which key goes where.
There are three credentials in play, and they serve three different purposes. The first is the user session, a signed cookie inside each product. Core has its own, Desk has its own, and they never mix. This proves who a person is to one product.
The second is the gateway API key, sent on outbound calls from products to the gateway. Desk uses it when it asks the gateway to verify a token. This proves which system is calling the gateway.
The third is the identity token itself, signed by Core with the tenant's private key. This is the portable claim that moves across the ecosystem.
There's also an inbound key for Desk's machine-to-machine bridge, so that only authorized systems can push identity exchanges into Desk. It lives in Desk's environment and is provided by whoever plays the role of the caller.
Every credential has exactly one job. No credential is shared between two roles. And the identity token, the one thing that actually moves, is the one thing guarded by public key cryptography rather than by secrecy.
What Making This Real Actually Took
I want to show what the implementation work looked like, because it's easy to describe an architecture and harder to feel what building it alone actually takes.
I wrote a small signing utility inside Core. It reads the private key from the environment, reads the tenant slug the same way, and produces a signed token with the issuer set correctly. It makes sure the configured algorithm is respected and the expiry is sane. If the key is missing, it fails loudly rather than issuing unsigned identity.
I added an identity module to Core with two endpoints. One issues a token for the authenticated user, pulling the user's record, their branch, their role, and building the claim set from real data rather than from client input. The other endpoint returns the deployment's public key, which is what makes standalone key registration possible.
I added a verification service to the gateway. It takes a token, resolves the issuer, loads the tenant's public key, checks the tenant is active, and verifies the signature. Every failure mode has a named reason, so when a verification fails I know whether the token was malformed, the tenant was unknown, the tenant was inactive, the tenant had no key, or the signature didn't match. This makes debugging a flow issue completely different from debugging a cryptographic issue, and that pays for itself constantly, especially working solo with no one else to bounce a stack trace off of at midnight.
I added a bridge into Desk. Desk's exchange endpoint takes the token, sends it to the gateway for verification, waits for a valid response, and only then looks up its own tenant by the verified tenant reference and reconciles the customer record. Desk never touches the raw client payload before the gateway vouches for it. It consumes verified identity, nothing else.
I wrote tests at every layer. The signing utility is tested with real generated key pairs, proving that a token signed by the right key verifies, and a token signed by the wrong key does not. The gateway service is tested against every failure reason. The Desk bridge is tested for the outbound verification path. This is the kind of cryptography where an untested bug feels identical to a security hole, so I didn't ship a single layer without its suite, knowing there was no second engineer to catch what I missed.
And then I ran the loop by hand. I logged into Core, asked for a token, took that token and sent it to the gateway's verify endpoint in Postman. It came back valid, with the exact claims Core had signed. The signature was real, the issuer was right, the tenant lookup worked, and Desk had a verified identity it could consume. When that response came back valid, honestly, it was one of the more satisfying moments of the project. The whole chain had fired for the first time, and it was airtight, and I'd built every piece of it myself.
The Killer Point
Now I arrive at the point I flagged at the start, and it's the one that makes the architecture worth its weight.
Adding a new Monesize product does not require redesigning the identity architecture.
Think about what a new product has to do. It needs to recognize users from Core, and it needs to recognize tenants. With this design, the new product wires the gateway verification into its login or bridge flow, and it's done. It doesn't need to replicate Core's auth. It doesn't need to peer with Core's database. It doesn't need to hold any of Core's secrets. It sends tokens to the gateway, the gateway vouches for the identity, and the new product trusts the verification result.
The new product isn't special. It follows the same contract every other consumer follows. The gateway doesn't need a new endpoint for it. The tenants don't need new keys for it. The trust model doesn't need a new rule for it. It plugs into a mechanism that already exists and is already proven.
That's what makes an identity layer a platform rather than a solution to a specific problem. The first integration is the hard one, because you're building the mechanism. Every integration after that is a client of something that already works.
This is the property that matters most to a product ecosystem, because an ecosystem earns its premium through the pace of adding things. Every product that can connect without rebuilding identity is a product that ships faster, with less risk, and without weakening the security of any other product. As the only engineer building this, that pace mattered even more to me than it might on a larger team, since every hour not spent reinventing identity for a new product was an hour I got to spend somewhere it actually counted.
What I Would Tell Someone Starting This
If you're starting something similar, here's the honest advice I'd give myself.
Do not share secrets between services just because it's easier. The one-time convenience of a shared secret is paid for forever in blast radius. The asymmetric setup costs you a few environment variables and a couple of extra steps, and it buys you a verifier that cannot forge, forever.
Make the verifier structurally unable to mint. This single decision is worth more than any amount of policy, role definitions, or middleware. When the trust anchor cannot produce identity, the compromise model collapses to one thing at a time instead of all things at once.
Keep tokens short-lived. One hour is a good default. Short expiry keeps leaked tokens worthless quickly and keeps the signed claims fresh.
Name your failure reasons. A verifier that returns five distinct reasons is five times easier to operate than one that returns a generic failure. You will spend real time diagnosing integration issues, and named reasons turn guesswork into lookup, which matters even more when you're the only person who's going to be doing that diagnosing.
Write tests against real keys. Generate real key pairs in your test suite, sign with one, verify with the other, sign with the wrong key and confirm it fails. Cryptography that isn't tested this way isn't really tested.
And above all, design for the third product. Ask yourself often: if you built a brand new product tomorrow, could it plug into this identity layer without changing the architecture? If the answer is no, you've built a solution to one problem instead of a foundation for many, and the difference is the entire game.
Looking Back
I did not set out to build cryptography. I set out to let independent products trust each other, and the requirements refused to allow anything weaker.
A product authenticates its own users. The trusted identity authority signs an identity assertion. Tenant-specific public keys are registered with the gateway. Private signing keys never leave the issuing product. The gateway verifies signatures cryptographically. Downstream products consume verified identity rather than trusting claims supplied directly by clients. Each product keeps its own sessions, its own database, and its own authorization model. And a new Monesize product plugs into the existing identity layer without redesigning anything.
I've seen a lot of architectural decisions where the "right" answer is also the complicated one. This one was the opposite. The cryptographically sound answer turned out to be the simpler one to scale, the safer one to operate, and the only one that made the ecosystem independent product by product.
And that last point, adding a product without redesigning identity, is the killer. It's the difference between building for today and building for the next five products. I chose the second one, and I'm very glad I did.
Comments
Post a Comment