<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Fulla Blog</title>
        <link>https://fulla.dev/zh-CN/blog</link>
        <description>Engineering notes on building a C++17 IAM core</description>
        <lastBuildDate>Sat, 19 Sep 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>Copyright © 2026 Luca · AGPL-3.0</copyright>
        <item>
            <title><![CDATA[Why we built an OAuth2 server in C++ — and benchmarked it against Keycloak, Ory, and Zitadel]]></title>
            <link>https://fulla.dev/zh-CN/blog/why-cpp-oauth2-server</link>
            <guid>https://fulla.dev/zh-CN/blog/why-cpp-oauth2-server</guid>
            <pubDate>Sat, 19 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[An embeddable identity engine in C++17: the motivation, a reproducible same-environment benchmark against three mature competitors, and what the process taught us beyond the rankings.]]></description>
            <content:encoded><![CDATA[<p>Every identity server you can self-host today is a <em>process</em>: a JVM, a Go
binary, a Node app. None of them is a <em>library</em>. That gap is where Fulla
starts — and it's also why we ended up writing an OAuth2/OIDC server in
C++17 and benchmarking it, on identical hardware, against Keycloak, Ory
Hydra, and Zitadel.</p>
<p>This post covers four things: why the embeddable niche matters (§1), how we
made a four-product comparison as fair as we could — including two findings
that forced us to <em>withdraw</em> claims rather than publish them (§2), the
numbers with their explicit limits (§3), and what the benchmarking process
taught us beyond rankings (§4). Everything here is reproducible from the
repository.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-why-c-for-identity-infrastructure">1. Why C++ for identity infrastructure?<a href="https://fulla.dev/zh-CN/blog/why-cpp-oauth2-server#1-why-c-for-identity-infrastructure" class="hash-link" aria-label="1. Why C++ for identity infrastructure?的直接链接" title="1. Why C++ for identity infrastructure?的直接链接" translate="no">​</a></h2>
<p>If you operate a normal web product, you are spoiled for choice: run
Keycloak, run Ory, run Zitadel, buy Auth0. Each is a standalone service you
deploy next to your application. That model works — most of the time.</p>
<p>It stops working in a few specific places. A game server that wants to
issue its own tokens without an extra network hop on the hot path. An edge
box or an IoT gateway that has no room for a JVM. A C++ trading system
whose team would rather not add a second runtime to their audit surface.
In those worlds, "just deploy an identity provider" means shipping real
infrastructure for what could have been a dependency.</p>
<p>Fulla is that dependency. It is an OAuth2/OIDC engine written in C++17
that you consume the way you consume any other library:</p>
<div class="language-cmake codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-cmake codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">find_package(fulla-oauth2 REQUIRED)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">target_link_libraries(your-app PRIVATE fulla::oauth2)</span><br></div></code></pre></div></div>
<p>The engine assembles from SDK packages the same way in your build tree as
in ours. To quantify what "library-sized" means, we built a minimal host —
<a href="https://github.com/voidvec/fulla/tree/master/examples/third-party-host" target="_blank" rel="noopener noreferrer" class="">184 lines of C++</a>
that links only the SDK packages and drives the authorization-code flow's
core steps (scope decision → code issuance → token exchange) end to end.
Peak working set: <strong>2.5 MB</strong>. Binary: 12 MB. There is no database
requirement — repositories are ports, and an in-memory implementation ships
in the SDK for tests and embedded use.</p>
<p>None of this is a claim that C++ is <em>the</em> language for identity. It's a
claim that one niche — identity embedded in a C++ host — had no first-class
option. Performance, for the record, is the <em>output</em> of that choice, not
the reason for it; we'll get to the measured numbers, and their limits, in
§3.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-how-to-benchmark-four-identity-servers-fairly">2. How to benchmark four identity servers fairly<a href="https://fulla.dev/zh-CN/blog/why-cpp-oauth2-server#2-how-to-benchmark-four-identity-servers-fairly" class="hash-link" aria-label="2. How to benchmark four identity servers fairly的直接链接" title="2. How to benchmark four identity servers fairly的直接链接" translate="no">​</a></h2>
<p>Comparisons are cheap to publish and expensive to trust. Most "X vs Y"
posts measure one product carefully and the others as an afterthought. We
tried to do the opposite: one methodology, applied identically, with every
raw result committed to the repository.</p>
<p><strong>The environment.</strong> A single WSL2 host (8 vCPU, 16 GB RAM) running all
four products serially, with <code>docker compose down -v</code> between products so
no state leaks across sessions. One PostgreSQL 17 instance for whichever
product was under test. Load from wrk 4.1.0, the same staircase everywhere:
2 → 4 → 8 → 16 → 32 → 64 → 128 connections, 5 s warmup (discarded), 10 s
measured per level.</p>
<p><strong>The competitors.</strong> Keycloak 26.7.1, Ory Hydra v26.2.0, Zitadel v4.17.1 —
each on its officially recommended configuration. Two decisions worth
calling out:</p>
<ul>
<li class=""><em>Zitadel version.</em> We deliberately picked v4 over the older v2 line that
most tutorials use: v2 is two majors behind, and the eventstore and
projection rework in v4 is exactly what their own published benchmarks
measure. Benchmarking a stale major would misrepresent the product.</li>
<li class=""><em>Connection pools.</em> Where a product has a pooling mechanism, we aligned
it to 25 connections (appendix A of the report documents each product's
knob). Fulla runs its documented benchmark profile — pools 64/64, cache
on, batch mode, <code>reuse_port</code>, opt-in LTO build, 30-second sessions —
which is the configuration our performance docs recommend, not a hidden
tuning fork.</li>
</ul>
<p><strong>The scenarios.</strong> Five, each isolating a different path: <code>discovery</code>
(static metadata — the framework ceiling), <code>client_credentials</code> (RS256
signing + a client lookup + token persistence), <code>introspect</code> (RS256
verification + a live-status lookup), <code>refresh_token</code> (rotation + reuse
detection + new issuance), and <code>userinfo</code> (bearer validation + a user
record read). The authorization-code + PKCE flow (our S4) is <em>not</em> in the
suite: it's a multi-step browser flow whose orchestration differs too much
across products to stage comparably. We say so in the report rather than
pretending otherwise.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="two-findings-we-withdrew">Two findings we withdrew<a href="https://fulla.dev/zh-CN/blog/why-cpp-oauth2-server#two-findings-we-withdrew" class="hash-link" aria-label="Two findings we withdrew的直接链接" title="Two findings we withdrew的直接链接" translate="no">​</a></h3>
<p>The methodology earned our trust the hard way — twice.</p>
<p><strong>The Keycloak user-pool expiry.</strong> In the first full session, Keycloak's
userinfo numbers collapsed to 100% errors at higher concurrency. It would
have been easy to publish that. Instead we dug in: our refresh-token
staircase had re-signed ~90k refresh tokens, which aged the shared user
pool past the realm's 1-hour <code>accessTokenLifespan</code> — every userinfo call
was correctly rejecting expired tokens. The bug was in our staging, not in
Keycloak. We fixed it (the pool is re-minted before userinfo, baked into
<code>keycloak/run-all.sh</code>) and re-ran the scenario in a targeted session. The
numbers you see below are from that corrected run.</p>
<p><strong>The GC-jitter claim we decided not to make.</strong> The obvious marketing
narrative for a C++ IAM is "no GC, no tail-latency spikes." When we ran a
five-minute latency series for all four products, every one of them — JVM,
Go, and C++ alike — showed the same ~1.8 s periodic spikes, at the same
time, on the same machine. Four-way agreement is not evidence about
runtimes; it's evidence about the host. So we withdrew the claim entirely:
this post makes <strong>no tail-latency-smoothness argument, in either
direction</strong>, until we can re-measure on bare metal.</p>
<p>Both stories are in the report, not just in this post. That's the point:
if a benchmark can embarrass its author, it's probably honest.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="reproducibility">Reproducibility<a href="https://fulla.dev/zh-CN/blog/why-cpp-oauth2-server#reproducibility" class="hash-link" aria-label="Reproducibility的直接链接" title="Reproducibility的直接链接" translate="no">​</a></h3>
<p>The report is generated, not written:
<a href="https://github.com/voidvec/fulla/blob/master/benchmarks/reporting/gen-comparison.py" target="_blank" rel="noopener noreferrer" class=""><code>gen-comparison.py</code></a>
aggregates the committed JSONs (no hand-entered numbers, newest
same-session group wins) into <code>COMPARISON.md</code>. One command re-runs a
product's suite; one command regenerates the report. If your hardware
differs from ours, run it — we'd genuinely rather see your numbers than
win an argument.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-results--with-their-limits">3. Results — with their limits<a href="https://fulla.dev/zh-CN/blog/why-cpp-oauth2-server#3-results--with-their-limits" class="hash-link" aria-label="3. Results — with their limits的直接链接" title="3. Results — with their limits的直接链接" translate="no">​</a></h2>
<p><img decoding="async" loading="lazy" alt="Five-scenario comparison: Fulla vs Keycloak vs Ory Hydra vs Zitadel, steady-state QPS" src="https://fulla.dev/zh-CN/assets/images/five-scenarios-dd54bc90fdfadf69de488c938f20c73b.png" width="4354" height="1220" class="img_ev3q"></p>
<p>Steady state below means the highest concurrency level whose error rate
stayed under 0.01%:</p>
<table><thead><tr><th>Scenario</th><th>Fulla</th><th>Keycloak</th><th>Ory Hydra</th><th>Zitadel</th><th>Fulla vs best</th></tr></thead><tbody><tr><td>S1 discovery</td><td><strong>87,499</strong></td><td>41,086</td><td>1,713</td><td>8,746</td><td>2.1×</td></tr><tr><td>S2 client_credentials</td><td><strong>14,438</strong></td><td>5,634</td><td>2,159</td><td>1,679†</td><td>2.6×</td></tr><tr><td>S3 introspect</td><td><strong>22,458</strong></td><td>10,637</td><td>11,454‡</td><td>3,142†</td><td>2.0×</td></tr><tr><td>S5 refresh_token</td><td><strong>5,506</strong></td><td>2,898</td><td>738</td><td>N/A</td><td>1.9×</td></tr><tr><td>S6 userinfo</td><td><strong>49,302</strong></td><td>32,704</td><td>10,089</td><td>3,556</td><td>1.5×</td></tr></tbody></table>
<p>† Zitadel's official machine-to-machine path is the RFC 7523 jwt-bearer
grant with private-key JWT — we benchmarked their documented path rather
than forcing client_secret semantics onto it. Machine users on that path
receive no refresh tokens, hence the S5 N/A.</p>
<p>‡ Hydra's introspection was measured through its admin API, its documented
route for the operation.</p>
<p><img decoding="async" loading="lazy" alt="Cold start comparison: Fulla 1.26s vs Ory 4.4s, Zitadel 5.3s, Keycloak 18.3s" src="https://fulla.dev/zh-CN/assets/images/cold-start-fb7fe27625a4676be3f73ded22422551.png" width="1880" height="840" class="img_ev3q"></p>
<p>Cold start — clean volume, auto-migration included, median of recorded
runs — is the most lopsided picture: <strong>1.26 s</strong> for Fulla against 4.4 s
(Hydra), 5.3 s (Zitadel), and 18.3 s (Keycloak). For autoscaling pools and
edge deployments, that gap compounds.</p>
<p>Now the limits, because they are part of the result:</p>
<ul>
<li class=""><strong>87k is not "the QPS."</strong> It's the <em>stateless</em> discovery endpoint. Token
issuance — the number that pays the database round-trip and an RS256
signature — is 14.4k. Any summary of this post that says "100k+ QPS" has
already misquoted it.</li>
<li class=""><strong>These are lower bounds.</strong> All runs stayed under 44% driver CPU on a
virtualized host. On bare metal every product moves; the ratios, we
believe, are the durable part.</li>
<li class=""><strong>Memory has two honest numbers, not one.</strong> Embedded as a library, Fulla
peaks at <strong>2.5 MB</strong> (§1). As a full container stack — Postgres, Redis,
connection pools, the works — it is the <em>heaviest</em> of the four at
~2.35 GB, with Ory the lightest at 269 MB. If your constraint is
host-process footprint, the first number is yours; if it's rack density,
Ory deserves your attention. We're not going to blur the two scopes into
one flattering figure.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-what-the-benchmark-taught-us-beyond-rankings">4. What the benchmark taught us beyond rankings<a href="https://fulla.dev/zh-CN/blog/why-cpp-oauth2-server#4-what-the-benchmark-taught-us-beyond-rankings" class="hash-link" aria-label="4. What the benchmark taught us beyond rankings的直接链接" title="4. What the benchmark taught us beyond rankings的直接链接" translate="no">​</a></h2>
<p>The comparison produced four lessons that outlive the leaderboard. All of
them are encoded as comments in the benchmark scripts, which is where
lessons belong.</p>
<p><strong>"Compose up auto-seeds" is a myth.</strong> Postgres's <code>initdb</code> does not
recurse into mounted subdirectories, and the app's migration runner does
schema only — never seed data. Several of our early "benchmark results"
were actually measuring empty-database fast paths. The fix is unglamorous
and now documented: apply seed SQL explicitly, with a retry loop that
tolerates the migration runner's startup race.</p>
<p><strong>Refresh-token pools are ammunition, not fuel.</strong> Fulla rotates refresh
tokens and revokes the family on reuse detection — which means every
refresh token is single-use. A naive load script re-uses tokens and
measures the <em>reuse detector</em>, not the refresh path. The refresh scenario
now re-mints a fresh token pool before every concurrency level
(<code>--reseed</code>). If you benchmark any server with rotation, check what your
load tool actually does here before trusting the output.</p>
<p><strong>Rate limiters punish the benchmark, not just the bug.</strong> Our token
endpoint's failure limiter is keyed on <code>(ip, client_id)</code> — one shared
bucket. A single buggy early run burned the failure budget for <em>every</em>
virtual user, and everything after it ate 429s for a minute. The symptom
looks like a server problem; the cause is one mis-scripted client sharing
a bucket with a hundred well-behaved ones.</p>
<p><strong>Session retention is TTL-bounded by design — measure before you call it
a leak.</strong> During long staircases, Fulla's memory climbed with
concurrency, which looked like a leak. It wasn't: sessions live as long as
the session timeout, so memory is bounded by TTL × arrival rate. Dropping
the benchmark profile's session timeout from 120 s to 30 s (a
retention-bounded profile) improved steady-state throughput <em>and</em> cut
full-stack RSS from 5.35 GB to 2.35 GB. The investigation, the formula,
and the mitigation guidance are all in the performance docs — because
"it's not a leak, here's the math" is only convincing when you show the
math.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="5-what-fulla-is-and-whats-next">5. What Fulla is, and what's next<a href="https://fulla.dev/zh-CN/blog/why-cpp-oauth2-server#5-what-fulla-is-and-whats-next" class="hash-link" aria-label="5. What Fulla is, and what's next的直接链接" title="5. What Fulla is, and what's next的直接链接" translate="no">​</a></h2>
<p>Fulla is an open-source identity core for C++17: OAuth2 and OIDC covering
authorization-code with mandatory PKCE, client credentials, refresh
rotation with reuse detection, device flow, introspection, revocation, and
RP-initiated logout — plus an admin console, a user portal, organizations
with member management and self-service application registration (v1.4.0),
Helm and Docker deployment, and a Postgres-backed storage layer with an
optional Redis cache. AGPL-3.0, Open Core. Three ways to consume it:</p>
<ul>
<li class=""><strong>C++ SDK</strong>: <code>find_package</code> — the embedded path this post is about, with
an <a href="https://fulla.dev/docs/sdk/sdk-integration-guide" target="_blank" rel="noopener noreferrer" class="">integration guide</a>
and a runtime contract.</li>
<li class=""><strong>Python client</strong>: <code>pip install fulla-oauth2</code></li>
<li class=""><strong>Go client</strong>: <code>go get github.com/voidvec/fulla/clients/go</code></li>
</ul>
<p>Documentation lives at <a href="https://fulla.dev/" target="_blank" rel="noopener noreferrer" class="">fulla.dev</a> — including
<a href="https://fulla.dev/llms.txt" target="_blank" rel="noopener noreferrer" class=""><code>llms.txt</code></a> and
<a href="https://fulla.dev/llms-full.txt" target="_blank" rel="noopener noreferrer" class=""><code>llms-full.txt</code></a>, so if you're an AI
agent (or building one) reading this, the docs are machine-readable too.
The benchmark methodology, raw JSONs, and report are in
<a href="https://github.com/voidvec/fulla/blob/master/benchmarks/competitors/results/COMPARISON.md" target="_blank" rel="noopener noreferrer" class="">the repository</a>.</p>
<p>What's next, in order: organization-aware protocol semantics (organization
claims and admin consent for B2B use), becoming a fully MCP-compliant
authorization server (RFC 8707 audience binding, RFC 9207, CIMD) plus OIDC
upstream federation — and the federation features we honestly still lack,
SAML and SCIM, stay customer-driven, smallest viable subset first. A
bare-metal re-measurement is also on the list so the tail-latency question
we withdrew in §2 can get a real answer.</p>
<p>If any of this is useful to you, the repo is
<a href="https://github.com/voidvec/fulla" target="_blank" rel="noopener noreferrer" class="">voidvec/fulla</a> — star it if you're so
inclined. Better yet, run the comparison on your hardware and tell us what
you see.</p>]]></content:encoded>
            <category>cpp</category>
            <category>oauth2</category>
            <category>oidc</category>
            <category>benchmark</category>
            <category>iam</category>
        </item>
    </channel>
</rss>