# An identity provider for 46 MiB

> What a platform actually needs from an IdP, what that costs when it ships as one native binary, and where the measurement stops being a comparison.

Page: https://kotlin.website/blog/an-identity-provider-for-forty-six-megabytes · Published: 2026-08-19

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](https://github.com/youndie/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`/`apply` is 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:

```kotlin
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:

```kotlin
dependencies {
    implementation("io.github.youndie.shildik:oidc-auth-server:$version")
    implementation("io.github.youndie.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](https://github.com/youndie/shildik)
