Keycloak was answering three questions for us — who is this service, who is this person, and which keys verify their tokens — and asking for 640 MiB and a Postgres of its own to do it. Nothing about those three questions is heavy.
shildik answers them from one Kotlin/Native binary against one Postgres, and it is open now. This is what it closes, and what it costs while closing it.
What it closes
Service-to-service authorization. client_credentials, JWKS, discovery. A service asks for a token, presents it to a neighbour, and the neighbour verifies the signature with keys it fetched itself. This is the safest part of OAuth2 — no redirects, no sessions, no cookies — and for most platforms it is also the only part they use.
Browser sign-in. Authorization code with PKCE, id_token, userinfo, end_session, and refresh rotation with replay detection. Enough for next-auth or oauth2-proxy to treat it as an ordinary provider, because that is what they were tested against.
Sign-in methods as modules. Google, a magic link from an email, a password. Which ones a build carries is decided by its dependencies, not by configuration: a distribution without auth-password cannot be talked into having passwords. In Keycloak the same magic link was a jar built against somebody else's SPI and loaded into somebody else's process; here it is a module implementing one interface.
An internal contour where a public provider makes no sense. Behind an oauth2-proxy in front of your own monitoring, a Google account is the wrong question — the tenant is closed, and people are provisioned by an administrator. Same code, different main().
Configuration that lives in git. No admin console: a CLI creates clients, roles, users and keys, export writes the lot to a file and apply puts it onto an empty instance. That is the thing a console-configured Keycloak could not do, and the reason a contour could not be raised again.
Key rotation nobody notices. Several signing keys live side by side, JWKS serves all of them, consumers pick by kid, and a retired key stays published for 24 hours because that is how long a typical validator caches JWKS.
What it costs
Three pods in production, measured over a 20-hour window by the monitoring that watches everything else here:
| Resident memory | 45–48 MiB steady, 53.7 MiB peak — on the first sample after boot |
| Memory limit it lives under | 256 MiB of cgroup, so the limit is four times the working set |
| CPU | 6–7 per mille of a core, at 0.1 request/second |
| Threads | 19–23 |
The peak matters more than the median: it is what a limit has to survive, and it happens at start-up rather than under load.
The comparison you want is not in that table. The obvious next line would be "against N MiB on the JVM", and our agent cannot give it honestly: for a JVM process it reports heap, and for a native one it reports RSS. A neighbouring JVM service in the same cluster shows a 21–34 MiB sawtooth against a 62 MiB max heap — which is not a number you may put next to 46 MiB of RSS, because it is not the same quantity. The JVM build of this same code exists and is kept as a rollback path; measuring both as RSS, from outside the process, is the only version of that comparison worth publishing, and I have not run it.
So: 46 MiB of RSS, from monitoring, for a provider serving two live contours. Take the axis with the number or leave both.
What it does not do
Useful to know before you try it, because none of this is on the roadmap by accident:
- no SAML, no SCIM, no LDAP federation;
- no admin UI — the CLI is the interface, and
export/applyis the reason; - no user self-service: no password reset flow, no profile page, no consent screen;
- no token introspection or revocation endpoint;
- one tenant per installation in practice, though the model has tenants;
- no clustering of its own — it is stateless, and two pods behind a service is the whole story.
Assembling one
A distribution is a main() that says which storage and which sign-in methods it wants:
fun main() =
runShildik(
storage = { config -> sqlx4kStorageModule(config.jdbcUrl, config.dbUser, config.dbPassword) },
) {
buildList {
optional("GOOGLE_CLIENT_ID")?.let { add(GoogleAuthMethod(it, /* … */)) }
optional("MAGIC_HANDOFF_SECRET")?.let { add(MagicLinkAuthMethod(it)) }
}
}
No key, no method — and the sign-in it powers simply is not offered. That is the whole configuration story: a build carries what it was given.
For the services on the other side, the same repository publishes the libraries they need — a token validator and a service-token client, both multiplatform, so a service that moves to Kotlin/Native keeps the code it already had:
dependencies {
implementation("ru.workinprogress.shildik:oidc-auth-server:$version")
implementation("ru.workinprogress.shildik:oidc-auth-client:$version")
}
Whether it is for you
If you need SAML, delegated administration or a consent UI, use Keycloak — it is good at exactly the things left out here. If what you need is the three questions at the top of this page, and you would rather not run a JVM and a second database to get answers, this fits in one binary, one Postgres and 46 MiB.
Source: github.com/youndie/shildik