<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
  <title>cwvermaak.dev</title>
  <link>https://cwvermaak.dev/</link>
  <description>Notes on identity &amp; access, multi-tenant SaaS, and the audit discipline that keeps systems honest. By Christiaan &quot;Wimpie&quot; Vermaak.</description>
  <language>en</language>
  <lastBuildDate>Fri, 18 Sep 2026 06:44:02 +0000</lastBuildDate>
  <atom:link href="https://cwvermaak.dev/feed.xml" rel="self" type="application/rss+xml" />
  <item>
    <title>Per-tenant signing keys in a multi-tenant OIDC issuer</title>
    <link>https://cwvermaak.dev/blog/per-tenant-signing-keys-oidc</link>
    <guid isPermaLink="true">https://cwvermaak.dev/blog/per-tenant-signing-keys-oidc</guid>
    <pubDate>Thu, 28 May 2026 00:00:00 +0000</pubDate>
    <description>One binary, many tenants, and a different signing key for each. How WeldForge keeps token issuance isolated without running an issuer per customer.</description>
    <category>identity</category>
    <category>oidc</category>
    <category>multi-tenancy</category>
    <category>spring-boot</category>
    <content:encoded><![CDATA[<p>A single-tenant OIDC provider has one signing key. It publishes the public half at <code>/.well-known/jwks.json</code>, signs every ID token and access token with the private half, and every relying party verifies against that one key. Simple.</p>
<p>Multi-tenancy breaks that comfortable assumption. If tenant A and tenant B share a signing key, then a token minted for A is cryptographically indistinguishable from one minted for B. That's not an isolation boundary — it's a shared secret with extra steps.</p>
<h2>The requirement</h2>
<p>In <a href="https://weldforge.org">WeldForge</a>, every tenant gets its own issuer identity:</p>
<ul>
<li>a distinct <code>issuer</code> URL (<code>https://id.example.com/t/{tenant}</code>),</li>
<li>a distinct JWKS endpoint,</li>
<li>and its <strong>own signing key</strong>, rotated independently.</li>
</ul>
<p>A token from one tenant must fail verification against any other tenant's keys. Not by policy — by mathematics.</p>
<h2>The shape of the solution</h2>
<p>Each tenant has a small set of keys in the database, tenant-scoped like every other row:</p>
<pre><code class="language-sql">CREATE TABLE signing_key (
    id           UUID PRIMARY KEY,
    tenant_id    UUID NOT NULL REFERENCES tenant(id),
    kid          TEXT NOT NULL,           -- key id, surfaced in the JWT header
    algorithm    TEXT NOT NULL,           -- e.g. RS256, ES256
    private_pem  TEXT NOT NULL,           -- encrypted at rest
    public_pem   TEXT NOT NULL,
    status       TEXT NOT NULL,           -- ACTIVE | NEXT | RETIRED
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);</code></pre>
<p>The <code>kid</code> is the hinge. It travels in the JWT header, so verification can select the right public key without guessing:</p>
<pre><code class="language-json">{ "alg": "RS256", "kid": "t_acme_2026_05", "typ": "JWT" }</code></pre>
<p>When a request comes in for tenant <code>acme</code>, the token service loads <code>acme</code>'s <code>ACTIVE</code> key, signs with it, and stamps the <code>kid</code>. The JWKS endpoint for <code>acme</code> publishes only <code>acme</code>'s public keys.</p>
<h2>Rotation without downtime</h2>
<p>The trick to rotating keys without invalidating live tokens is to publish the new public key <em>before</em> you sign with it. WeldForge models that with three states:</p>
<ol>
<li><strong>NEXT</strong> — generated and published in JWKS, but not yet signing anything.</li>
<li><strong>ACTIVE</strong> — the key currently signing new tokens. Exactly one per tenant.</li>
<li><strong>RETIRED</strong> — no longer signing, but kept in JWKS until every token it signed has expired.</li>
</ol>
<p>Promotion walks <code>NEXT → ACTIVE → RETIRED</code>. Because relying parties refresh JWKS and key-select by <code>kid</code>, a token signed by the outgoing key keeps verifying right up to its expiry. No flag day, no coordinated restart.</p>
<h2>The part that bites you</h2>
<p>The failure mode isn't the cryptography — it's a query that forgets its tenant. One unscoped <code>SELECT * FROM signing_key WHERE status = 'ACTIVE'</code> and you're suddenly signing tenant A's tokens with tenant B's key.</p>
<p>The defence is to make tenant-scoping the <em>default</em>, not a thing you remember to add. Every repository method takes a tenant context; the one place that can issue a query without it is small, audited, and loud. The audit webhook fires on every key promotion, HMAC-signed, so there's a tamper-evident trail of which key was active when.</p>
<blockquote>
<p>Multi-tenant isolation is a property you maintain on every single query, or it's a property you don't have.</p>
</blockquote>
<p>That's the whole game: not the clever bit, the boring bit, done every time.</p>]]></content:encoded>
  </item>
  <item>
    <title>Hello — and why this site exists</title>
    <link>https://cwvermaak.dev/blog/hello-and-why-this-site</link>
    <guid isPermaLink="true">https://cwvermaak.dev/blog/hello-and-why-this-site</guid>
    <pubDate>Wed, 20 May 2026 00:00:00 +0000</pubDate>
    <description>A short colophon. Who I am, what I&apos;ll write about here, and why the site you&apos;re reading runs on the same stack I ship to clients.</description>
    <category>meta</category>
    <category>tina4</category>
    <content:encoded><![CDATA[<p>I'm Christiaan — most people call me Wimpie. I'm a software architect in Johannesburg, working the seam where development, infrastructure, and quality assurance meet. This is where I'll write the notes that don't fit in a commit message.</p>
<h2>What this site is for</h2>
<p>Three kinds of thing end up here:</p>
<ul>
<li><strong>Identity &amp; access</strong> — OIDC, SAML, SCIM, PKI, and the unglamorous plumbing of getting them to coexist in one binary. This is what I build <a href="https://weldforge.org">WeldForge</a> around.</li>
<li><strong>Multi-tenant SaaS</strong> — the architecture patterns, the data-isolation decisions, and the mistakes I'd rather you skip.</li>
<li><strong>Audit &amp; QA discipline</strong> — the part everyone agrees matters and nobody budgets for.</li>
</ul>
<p>Less <em>thought-leadership</em>, more <em>field notes</em>: the decisions, the trade-offs, and the things I wish I'd known a year earlier.</p>
<h2>A note on the stack</h2>
<p>This site runs on <a href="https://tina4.com">Tina4</a> — the same PHP framework I contribute to and ship to clients. Posts are plain Markdown files in a <code>content/posts/</code> folder; a small loader parses the front-matter and renders the body. No database, no build step, no CMS.</p>
<pre><code class="language-php">$post = App\Posts::find($slug);
return $response-&gt;render('pages/post.twig', ['post' =&gt; $post]);</code></pre>
<p>That's deliberate. If I'm going to recommend a tool, I should be willing to run my own name on it.</p>
<blockquote>
<p>Heritage in judgement. Modern in method.</p>
</blockquote>
<p>More soon.</p>]]></content:encoded>
  </item>
</channel>
</rss>
