NOTE

Building Solomik.Identity

The hard part was not login.

25 Jun 2026

Back to notes

Solomik.Identity started from a fairly practical observation.

The applications I am building through Solomik are intended for many of the same customers.

Building a separate identity system for each one would mean asking the same people to create and manage a different account for every product.

That did not make much sense.

It would also assume that every user belongs neatly to one organisation, which is not always true. The same person may need access to more than one tenant, sometimes with a different role—or access to a different application—in each one.

So the original idea was to create one shared identity system for Solomik applications.

A person could use the same credentials across products, while their actual access would remain explicitly scoped to the tenants and applications they were allowed to use.

It sounded like a shared-login problem.

It was not.

Shared login was the easy description

A shared account can tell an application who the user is.

That is useful, but it does not answer the questions that become important immediately afterwards.

Which organisation is the user acting for?

Which application are they trying to access?

Is that application available to that organisation?

What can the user do there?

And, perhaps more importantly, what should happen to an already-issued token when any of those answers change?

The same person might administer one product for one company, have read-only access to another, and have no access at all somewhere else.

A global Admin role would have been wonderfully simple.

It would also have been wrong.

So Solomik.Identity became less about sharing a login form and more about defining an access context.

A user, a tenant, and an application.

Everything else had to make sense inside that boundary.

The model came before the protocol

The codebase already contained ideas from an older direction.

Some of them could have been renamed. Others could have survived behind adapters and compatibility layers. With enough patience, almost any previous model can be kept alive.

Usually by making the new model slightly worse.

There were no production consumers depending on the old surface, so I chose the less diplomatic option: remove it and design a clean first version.

That meant treating users, tenants, applications, memberships, roles, and permissions as separate concepts instead of compressing them into a few convenient tables and hoping the service layer would remember what they meant.

A membership says that a user belongs to a tenant.

A role assignment says what that user can do in a particular application for that tenant.

Those are not the same thing.

Keeping them separate made removal and revocation much easier to reason about. Removing someone from an organisation should not leave an old role assignment quietly granting access. Disabling an application for a tenant should not require deleting the user. A role created for one application should not accidentally mean something in another.

None of this is especially glamorous.

Which is probably why it is easy to postpone until the model is already difficult to change.

The platform needed a real consumer

Before working seriously on Identity, I had spent time turning the common .NET infrastructure behind my projects into Solomik.Platform.

It handled the things platform packages tend to handle: authentication primitives, authorization support, configuration, observability, data protection, messaging, persistence conventions, and several other pieces that look very convincing inside their own repository.

A platform package can pass all its tests and still be unpleasant to use.

Solomik.Identity became its first serious consumer.

That exposed a useful difference between “this API is technically correct” and “I would like to configure this again in another service”.

Some abstractions survived exactly as designed. Others needed clearer boundaries, stricter defaults, or less magic.

This was one of the more valuable parts of the project. Identity was not built on top of a theoretical platform. It was the project that forced the platform to prove it could support something real.

A valid JWT can still be wrong

Self-contained tokens are convenient because an API can verify them without asking a central session store for permission on every request.

The inconvenient part is that a token can remain cryptographically valid after the access it represents has changed.

A role may have been removed.

A membership may no longer exist.

An application may have been disabled.

The signature is still correct. The expiry time is still in the future. The token is now a perfectly valid description of something that is no longer true.

For the first version, I used a small access-version mechanism.

Each user, tenant, and application context has a current version. The version is included when a token is issued and checked when the token is used. Any access-changing operation advances it, making older tokens stale.

It is not a central session store, and it does not pretend JWTs have become magically revocable. It is simply an additional piece of current state that the system can compare against the claim made by the token.

The important part was not the number itself.

It was making sure access changes and version changes could not drift apart.

I also resisted adding a cache before there was evidence that the database lookup needed one. Redis would have made the architecture diagram look more complete.

The system did not need a more complete diagram.

It needed predictable invalidation semantics.

An identity backend is not yet an identity product

For a while, Solomik.Identity was mostly a backend.

It could model access correctly, issue scoped tokens, handle invitations, and explain why a user could or could not sign in.

That was useful to the person reading the API.

Less useful to everyone else.

The next step was turning it into two small products.

The account application handles the user-facing side: sign-in, recovery, invitations, multi-factor authentication, and selecting the organisation and application context.

The admin application handles the privileged side: users, tenants, applications, memberships, roles, permissions, invitations, and effective access.

I considered putting everything into one frontend.

It would have reduced the number of repositories and deployment units.

It would also have mixed public account flows with a privileged management console, even though they have different users, different risks, and different release cycles.

So they remained separate.

The account surface can stay deliberately small. The admin surface can become more capable without expanding the public application. And if the system later gains a standards-based hosted login flow, the account application already has a natural place in that architecture.

The system can explain a denial

Access-control systems are very good at returning 403.

They are often less good at answering the next question.

Why?

An operator should not have to reconstruct a user’s effective access by manually joining memberships, application availability, role assignments, and permissions.

So the system has a read-only effective-access view that evaluates the same context and reports the blockers it finds.

Perhaps the tenant is disabled.

Perhaps the application is unavailable for that tenant.

Perhaps the user is a member but has no active role.

Perhaps the role belongs to the wrong context.

The important word is read-only. The diagnostic operation does not repair missing state, create access records, or make the model more convenient behind the operator’s back.

It explains the current truth.

That sounds like a support feature.

It is also an architecture test. If the system cannot explain why access exists, the access model is probably not as clear as it appears in code.

I deliberately did not call it OAuth

At several points, it would have been tempting to describe Solomik.Identity as an OAuth server or an OpenID Connect provider.

It has login flows. It issues tokens. It knows about applications.

Close enough, at least for a confident README.

But the current system uses a custom first-party account and scoped-token flow. It does not yet implement the standard protocol surface, client model, redirect validation, discovery metadata, ID tokens, consent, or the rest of the work required to earn those names.

That distinction matters.

OAuth and OpenID Connect are not labels for “a service that returns a JWT”. They are interoperability and security contracts.

The current domain model leaves a reasonable path towards them. Applications are already explicit. Access is already scoped. The user-facing account surface already exists. Roles and permissions do not need to be reinvented.

But the standards layer should be added with a mature implementation and a real consumer requirement, not approximated through a few endpoints with familiar names.

For now, the honest description is less impressive and more accurate:

Solomik.Identity is a shared, multi-tenant, multi-application identity and access product for Solomik applications.

No SSO yet.

No OpenID Provider yet.

No need to pretend otherwise.

AI helped build it, but did not get to decide what it was

The project used roughly the same AI-assisted workflow as this site.

ChatGPT helped with research, architecture discussions, threat modelling, plan reviews, and turning decisions into constrained implementation tasks.

Claude Code worked inside the repositories, changed the backend and frontends, ran builds and tests, and reported what it had done.

I reviewed the result, challenged assumptions, and decided what moved forward.

This division was particularly useful for identity work because plausible is not the same as safe.

A configuration can look reasonable and still trust too much.

A fallback can feel helpful and fail open.

A token-storage decision can make the user experience smoother and the security model worse.

Some of the most useful decisions in the project were decisions not to add something: no premature cache, no extra infrastructure service without a need, no persistent browser token just to make refresh more convenient, and no OAuth label before the protocol existed.

AI made the iteration faster.

It did not make those trade-offs disappear.

Version one is intentionally unfinished

The backend and both frontends now pass their current build, test, and product-contract checks.

The access model is coherent.

Invitations, recovery, MFA, administration, scoped authorization, and stale-token invalidation are in place.

The system is still not deployed as a finished public identity provider.

The next step is a production-like deployment rehearsal, followed by the first real application integration. That will test the parts that repository checks cannot: routing, certificates, email delivery, persisted cryptographic keys, migrations, restarts, and the general tendency of real infrastructure to object to assumptions made in development.

Later versions may add standard OAuth and OpenID Connect flows, single sign-on, external identity providers, passkeys, richer session management, and multi-instance deployment.

Or they may not add all of them.

The point of the first version was not to collect every feature associated with identity platforms.

It was to stop rebuilding identity separately, establish a clear model for access, and create a foundation that can evolve without lying about what it already is.

Solomik.Identity now knows who a user is, where they are acting, which application they are entering, what they can do there, and when an old token should no longer be believed.

Login is included too.

Which is useful, apparently.