<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anthony</title>
    <description>The latest articles on DEV Community by Anthony (@karmadon).</description>
    <link>https://dev.to/karmadon</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F499393%2Fadb9b8ea-e95f-4851-b3c5-f4e81566951d.jpeg</url>
      <title>DEV Community: Anthony</title>
      <link>https://dev.to/karmadon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/karmadon"/>
    <language>en</language>
    <item>
      <title>Rotating signing keys without breaking live tokens</title>
      <dc:creator>Anthony</dc:creator>
      <pubDate>Tue, 27 Oct 2026 15:29:24 +0000</pubDate>
      <link>https://dev.to/karmadon/rotating-signing-keys-without-breaking-live-tokens-43ae</link>
      <guid>https://dev.to/karmadon/rotating-signing-keys-without-breaking-live-tokens-43ae</guid>
      <description>&lt;p&gt;A signing-key rotation can be simple on paper: generate a new key, change the signer, and deploy. In a running service, that sequence can reject valid tokens. A token signed just before the change may remain valid for another fifteen minutes. At the same time, a receiving API may still have a JWKS snapshot without the new key.&lt;/p&gt;

&lt;p&gt;The failure is confusing because each component may look healthy. The auth server signs a token. The resource service is serving requests. The JWKS endpoint returns JSON. Yet a call fails because they disagree about &lt;em&gt;which public keys are available at that moment&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I built Orionis so the auth server can hold multiple signers, publish all their public keys in JWKS, and select one &lt;code&gt;active_kid&lt;/code&gt; for new tokens. That gives a planned rotation an overlap period.&lt;/p&gt;

&lt;p&gt;This article is about a routine, planned rotation of a service-token signing key. The goal is to change the key used for &lt;strong&gt;new&lt;/strong&gt; tokens while letting already issued tokens reach their normal expiry. That goal determines the order of every deployment step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four states
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T0  JWKS: [old]       signing: old
T1  JWKS: [old, new]  signing: old
T2  JWKS: [old, new]  signing: new
T3  JWKS: [new]       signing: new
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;T0 — current state.&lt;/strong&gt; Existing tokens carry the old &lt;code&gt;kid&lt;/code&gt;. Resource services can validate them with the old public key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;T1 — publish first.&lt;/strong&gt; Add the new signer while leaving the old signer active. Roll this state to &lt;em&gt;every&lt;/em&gt; auth-server instance before moving on. Every JWKS endpoint must be able to serve both public keys before any instance starts issuing tokens with the new &lt;code&gt;kid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;T2 — switch signing.&lt;/strong&gt; Change &lt;code&gt;active_kid&lt;/code&gt; to the new key, keeping both keys in JWKS. During this rollout, some auth instances may still sign with the old key while others sign with the new one. Both kinds of token remain verifiable because every instance publishes both public keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;T3 — retire old.&lt;/strong&gt; Count from the &lt;em&gt;last&lt;/em&gt; token signed by the old key. After the maximum token lifetime and allowed clock leeway have elapsed, remove the old signer and its public JWK. Do not count only from the moment the T2 rollout began.&lt;/p&gt;

&lt;p&gt;The local Orionis example uses a 15-minute access-token lifetime, but production TTL is configurable. The waiting period must follow the longest lifetime actually issued during the overlap.&lt;/p&gt;

&lt;p&gt;Notice the asymmetry between T1 and T3. Publishing a new &lt;em&gt;public&lt;/em&gt; key early only prepares verifiers for signatures they may soon see. Removing the old public key early has an immediate cost: a token that is still within its valid time window becomes unverifiable. Publication goes ahead of signing; removal trails it.&lt;/p&gt;

&lt;p&gt;The same pattern works when resource services have different JWKS cache ages. A receiver with an older set fetches when it sees the new &lt;code&gt;kid&lt;/code&gt;. A receiver that has already fetched both keys can validate either token locally. During T2, both must be valid states. Do not assume every receiver refreshes at the same instant.&lt;/p&gt;

&lt;p&gt;A useful clock calculation starts at the final old-signed token, not at deployment start. Suppose the last old token is signed at 12:10, the longest issued lifetime is 15 minutes, and every receiver uses Orionis's default 30-second verifier leeway. The earliest planned retirement is after 12:25:30. If any old signer remains during the T2 rollout, that time moves forward. A receiving service with a different leeway or an issuer with a longer TTL needs a later cutoff.&lt;/p&gt;

&lt;p&gt;I would write that cutoff down before the rollout. “Wait fifteen minutes” is too easy to apply from the wrong starting point. Record when the final auth instance stopped using the old signer, add the longest relevant token lifetime and the receiver's accepted clock leeway, then consider T3. If the last old-signed token time is uncertain, keep the overlap longer.&lt;/p&gt;

&lt;p&gt;Switching &lt;code&gt;active_kid&lt;/code&gt; does not edit tokens already in circulation. Even after every auth instance reports the new active key, consumers can legitimately receive old-signed tokens until those tokens expire or their callers replace them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the order matters with several instances
&lt;/h2&gt;

&lt;p&gt;Imagine two auth-server tasks behind one load balancer. Task A already signs with &lt;code&gt;new&lt;/code&gt;, but task B is still at T0 and publishes only &lt;code&gt;old&lt;/code&gt;. Billing receives a new token, sees an unknown &lt;code&gt;kid&lt;/code&gt;, and fetches JWKS. If that fetch reaches task B, the new public key is absent and the request fails. Making T1 a completed rollout before T2 removes that mismatch.&lt;/p&gt;

&lt;p&gt;The reverse mismatch happens at retirement. If one auth instance at T2 still signs with &lt;code&gt;old&lt;/code&gt; while another instance at T3 no longer publishes &lt;code&gt;old&lt;/code&gt;, a fresh token can be invalid as soon as it is issued, depending on which instance serves the JWKS fetch. T3 must wait until all signers have switched, not merely until a deployment controller has started replacing them.&lt;/p&gt;

&lt;p&gt;This is a distributed-systems problem hiding inside a configuration change. The useful unit of progress is “every serving instance has reached the required state, and the next state remains valid while instances overlap.” A single green deployment event does not establish that invariant by itself.&lt;/p&gt;

&lt;p&gt;This is also why I would verify the JWKS view through every serving instance or through an equivalent deployment check, rather than relying on one load-balanced response. A single successful fetch proves only that one response contained the key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat &lt;code&gt;kid&lt;/code&gt; as part of the key's identity
&lt;/h2&gt;

&lt;p&gt;Each signer needs a distinct &lt;code&gt;kid&lt;/code&gt;. Orionis rejects duplicate signer IDs when constructing the server. Its JWKS provider also rejects duplicate IDs in a fetched set. Those checks are useful, but they do not make reusing an old ID for different key material a good rotation strategy.&lt;/p&gt;

&lt;p&gt;A receiver with a fresh cached key for &lt;code&gt;kid=old&lt;/code&gt; will try that public key when it sees a token header naming &lt;code&gt;old&lt;/code&gt;. If the auth server has silently changed the private key behind the same ID, the receiver has no reason to fetch yet: the key is present and its cache is fresh. Signature verification fails until it refreshes. A new key deserves a new &lt;code&gt;kid&lt;/code&gt;, while the old key keeps its ID during the overlap.&lt;/p&gt;

&lt;p&gt;The token header's &lt;code&gt;kid&lt;/code&gt; is only a selector. It does not prove that a token came from Orionis, and it is not a URL to fetch. The receiving API uses its configured JWKS endpoint, obtains the public key for that ID, checks the algorithm, and verifies the signature and claims. The &lt;a href="https://github.com/stremovskyy/orionis/blob/v0.6.0/README.md#jwks-validation-lifecycle" rel="noopener noreferrer"&gt;JWKS validation notes&lt;/a&gt; describe that path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes in the config
&lt;/h2&gt;

&lt;p&gt;The standalone server accepts a &lt;code&gt;keys&lt;/code&gt; list and an &lt;code&gt;active_kid&lt;/code&gt;. Here is the relevant part of a T1 configuration; the rest of the server configuration is omitted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"active_kid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"old"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keys"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"kid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"old"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"private_key_pem_env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ORIONIS_SIGNING_KEY_PEM_OLD"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"kid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"new"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"private_key_pem_env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ORIONIS_SIGNING_KEY_PEM"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For T2, change only &lt;code&gt;active_kid&lt;/code&gt; to &lt;code&gt;new&lt;/code&gt;. Use distinct &lt;code&gt;kid&lt;/code&gt; values; reusing one for different public keys would make cache behavior ambiguous.&lt;/p&gt;

&lt;p&gt;I prefer to make T1 and T2 separate reviewable diffs. The T1 diff adds the new key reference while leaving &lt;code&gt;active_kid&lt;/code&gt; unchanged. The T2 diff changes one value: &lt;code&gt;active_kid&lt;/code&gt;. This gives a clear place to stop if key injection or JWKS publication is wrong.&lt;/p&gt;

&lt;p&gt;The standalone configuration validates that &lt;code&gt;active_kid&lt;/code&gt; names a loaded signer. A missing PEM environment variable fails key loading; conflicting &lt;code&gt;private_key_path&lt;/code&gt; and &lt;code&gt;private_key_pem_env&lt;/code&gt; settings are rejected. Catching these at startup is much safer than discovering a partially configured key set through failed API calls.&lt;/p&gt;

&lt;p&gt;The private Ed25519 PEM values belong in a secret manager. The production ECS template injects them as environment variables from AWS Secrets Manager. AppConfig contains only the variable names and the non-secret configuration, such as key IDs and client secret hashes.&lt;/p&gt;

&lt;p&gt;Private-key access and public-key publication have opposite requirements. Only the auth-server process should read the private PEM. The JWKS endpoint deliberately exposes the public counterpart so resource services can verify tokens. At T1, check secret injection and public JWKS output separately. A public key appearing in JWKS is expected; a private PEM appearing in logs or configuration history is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  A rollout I would put in a runbook
&lt;/h2&gt;

&lt;p&gt;I would make each transition observable and pause after it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Prepare &lt;code&gt;new&lt;/code&gt;.&lt;/strong&gt; Generate and store a new Ed25519 PKCS8 private key through the normal secret-management path. Choose a distinct &lt;code&gt;kid&lt;/code&gt;; keep the old key available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy T1.&lt;/strong&gt; Add both key references, keep &lt;code&gt;active_kid=old&lt;/code&gt;, and replace every serving auth task. Confirm every JWKS response can advertise both IDs while newly issued tokens still name &lt;code&gt;old&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy T2.&lt;/strong&gt; Change &lt;code&gt;active_kid&lt;/code&gt; to &lt;code&gt;new&lt;/code&gt; and replace all serving auth tasks. Confirm new tokens name &lt;code&gt;new&lt;/code&gt;, old-signed tokens still work, and a resource service validates new-signed tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait for the old acceptance window.&lt;/strong&gt; Start the clock after the last old-signing instance is gone. Use the longest token TTL and receiving leeway, including custom verifier configurations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy T3.&lt;/strong&gt; Remove the old signer and its public key, replace all auth tasks, and confirm JWKS contains only &lt;code&gt;new&lt;/code&gt;. Verify a fresh authorized request again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The checks need to reach the same topology that serves real traffic. If a load balancer hides individual task addresses, use deployment state plus a per-instance check or equivalent telemetry. Repeating &lt;code&gt;curl&lt;/code&gt; through the load balancer does not guarantee that you sampled every instance.&lt;/p&gt;

&lt;p&gt;For the T2 check, I would keep a known-good old token from before the switch and obtain a new-signed token after it. Both should validate at a resource service while still within their valid time windows. Keep those bearer values private; record the result and the relevant &lt;code&gt;kid&lt;/code&gt;, not the raw tokens, in the rollout log.&lt;/p&gt;

&lt;h2&gt;
  
  
  A rollback has its own overlap
&lt;/h2&gt;

&lt;p&gt;If the new signer causes a problem at T2, I can move &lt;code&gt;active_kid&lt;/code&gt; back to &lt;code&gt;old&lt;/code&gt; while continuing to publish &lt;strong&gt;both&lt;/strong&gt; public keys. Rolling tasks back to old signing stops new tokens from using the problematic key, but it does not erase tokens already signed by &lt;code&gt;new&lt;/code&gt;. Removing the new public key immediately would break those still-valid tokens.&lt;/p&gt;

&lt;p&gt;The same waiting rule applies in reverse: keep the key that verified already-issued tokens until their acceptance window closes, unless an incident requires deliberate early invalidation. A rollback plan should include the JWKS contents, not only the active signer field.&lt;/p&gt;

&lt;p&gt;Rollback has a deployment-order requirement too. If T2 already issued tokens under &lt;code&gt;new&lt;/code&gt;, rolling back to a task that serves only the original T0 JWKS set is unsafe. The useful rollback target is T1: publish both keys and sign new tokens with &lt;code&gt;old&lt;/code&gt;. That preserves verification for new-signed tokens already circulating while stopping further issuance under &lt;code&gt;new&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the problem is that some receivers have not learned the new key, restoring T1 buys time to repair their JWKS path. If the new private key may be compromised, the decision changes: continuing to publish its public key allows tokens signed under it to validate, including potentially malicious ones. That is an incident with an explicit availability-versus-revocation decision, not a routine rollback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remember how configuration reaches the process
&lt;/h2&gt;

&lt;p&gt;In the ECS setup described in the &lt;a href="https://github.com/stremovskyy/orionis/blob/v0.6.0/deploy/aws/ecs/README.md" rel="noopener noreferrer"&gt;deployment guide&lt;/a&gt;, Orionis fetches AppConfig once at startup, with a 30-second deadline. Activating a new AppConfig version alone does &lt;strong&gt;not&lt;/strong&gt; update running auth tasks. Each transition from T0 through T3 requires a rolling task replacement, followed by a check that the active signer and JWKS contents match the intended state.&lt;/p&gt;

&lt;p&gt;The startup model matters when reading deployment dashboards. An AppConfig deployment can be &lt;code&gt;COMPLETE&lt;/code&gt; while old ECS tasks still run with the configuration they fetched earlier. A new task can also fail startup if it cannot read AppConfig or the injected key environment variable. Treat configuration activation and task replacement as separate events, and verify live signer state after both complete.&lt;/p&gt;

&lt;p&gt;For T1, verify that every serving auth instance advertises both keys. For T2, request a new token and inspect its &lt;code&gt;kid&lt;/code&gt;, then make an authorized request through a resource service. Keep checking that a token signed just before the switch still works until it expires.&lt;/p&gt;

&lt;p&gt;For a simple smoke check, inspect the set of public key IDs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://auth.example.internal/.well-known/jwks.json |
  jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.keys[].kid'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At T1 and T2, expect both IDs. At T3, expect only the new one. The server startup log also reports &lt;code&gt;active_kid&lt;/code&gt; and signer count. Inspect a freshly issued token's header locally to confirm which &lt;code&gt;kid&lt;/code&gt; signed it; avoid pasting a real token into an external decoder.&lt;/p&gt;

&lt;p&gt;If AppConfig is unavailable at startup, the ECS task does not silently fall back to a previous config. In the deployment described here, signing-key PEMs are injected by ECS from Secrets Manager, while the application reads AppConfig at startup. That makes task health, startup logs, and an authorized end-to-end request useful checks after each rollout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnose a failed switch
&lt;/h2&gt;

&lt;p&gt;The first useful question is: which token &lt;code&gt;kid&lt;/code&gt; failed, and which keys did the relevant JWKS response contain? Those facts narrow the search without exposing the token itself.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Place to inspect first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New token names &lt;code&gt;new&lt;/code&gt;, but some JWKS responses contain only &lt;code&gt;old&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Incomplete T1 rollout or an old auth task still serving JWKS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JWKS contains &lt;code&gt;new&lt;/code&gt;, but one receiver rejects only new tokens&lt;/td&gt;
&lt;td&gt;Its JWKS URL, cache refresh, algorithm metadata, or signature verification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old-signed tokens fail immediately after T3&lt;/td&gt;
&lt;td&gt;Old public key retired before their acceptance window ended&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New auth tasks never become healthy&lt;/td&gt;
&lt;td&gt;AppConfig startup fetch, secret injection, &lt;code&gt;active_kid&lt;/code&gt;, or key-loading error&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a starting point, not a diagnosis from an HTTP status alone. The Gin middleware returns &lt;code&gt;401&lt;/code&gt; for token verification errors, including when an unknown &lt;code&gt;kid&lt;/code&gt; cannot be fetched. The caller's response does not say whether the signature was bad or JWKS was unreachable. Correlate it with server-side errors and deployment state without logging bearer tokens.&lt;/p&gt;

&lt;p&gt;One controlled pre-production exercise makes the invariant tangible: intentionally leave one auth instance at T0 and try a token signed under &lt;code&gt;new&lt;/code&gt;. The failed case shows why T1 must finish everywhere first. Then put every instance at T1 and repeat; both old and new tokens should have a verifiable public key. The exercise tests the rollout order before a real key change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smoke test that is easy to miss
&lt;/h2&gt;

&lt;p&gt;A single successful request after T2 proves less than it seems. The caller may still have an old token in its cache, so the request exercises only the old key. Or the request may hit a receiver whose JWKS cache already contains both keys, leaving another cold receiver untested.&lt;/p&gt;

&lt;p&gt;I would test four paths, each for a different reason:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;What it tells me&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Old token through a warm receiver&lt;/td&gt;
&lt;td&gt;The old key remains published and accepted during overlap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Newly issued token through a warm receiver&lt;/td&gt;
&lt;td&gt;A receiver with cached JWKS can discover or already use &lt;code&gt;new&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Newly issued token through a fresh receiver&lt;/td&gt;
&lt;td&gt;Cold JWKS fetch returns both keys and the token validates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Newly issued token after T3&lt;/td&gt;
&lt;td&gt;The retired key is gone without breaking the active one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the second path, make sure the token was actually minted after the switch and inspect its header locally. The orders client in the runnable example caches access tokens, so repeatedly running a &lt;em&gt;long-lived&lt;/em&gt; caller is not a reliable way to force a new token at an exact second. Request one directly from &lt;code&gt;/oauth/token&lt;/code&gt; or use a fresh client instance for the smoke check. Do not treat a green invoice call with an old token as proof that the new signer works.&lt;/p&gt;

&lt;p&gt;For the third path, a fresh resource-service instance can expose configuration mistakes that a warm instance masks. It must fetch JWKS from the configured URL before validating its first protected request. That path also tells you whether the auth server is reachable from the receiver's network environment, not just from the operator's shell.&lt;/p&gt;

&lt;p&gt;These are tests of the transition, not a demand to make every production consumer restart. The point is to cover both key IDs and both cache states while the overlap exists. Once T3 completes, the old-token test should end by natural expiry; it should not become a permanent fixture expecting an old key to remain published.&lt;/p&gt;

&lt;p&gt;This sequence is for routine rotation. A suspected private-key compromise is a different incident: retaining the old public key keeps tokens signed with that key verifiable during the overlap. Respond according to the compromise and revocation requirements of your system.&lt;/p&gt;

&lt;p&gt;The central rule is &lt;strong&gt;publish the new public key before using the new private key, and retain the old public key until old tokens are gone&lt;/strong&gt;. The &lt;a href="https://github.com/stremovskyy/orionis/blob/v0.6.0/docs/architecture.md#key-rotation-model" rel="noopener noreferrer"&gt;architecture notes&lt;/a&gt; and &lt;a href="https://github.com/stremovskyy/orionis/blob/v0.6.0/docs/wiki/Production-Security.md" rel="noopener noreferrer"&gt;production security guide&lt;/a&gt; contain the supporting model&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>jwt</category>
      <category>devops</category>
    </item>
    <item>
      <title>Balaka v0.3.0: OmniVoice 0.2.1, safer local defaults, and a better UI</title>
      <dc:creator>Anthony</dc:creator>
      <pubDate>Tue, 27 Oct 2026 10:30:42 +0000</pubDate>
      <link>https://dev.to/karmadon/balaka-v030-omnivoice-021-safer-local-defaults-and-a-better-ui-23o9</link>
      <guid>https://dev.to/karmadon/balaka-v030-omnivoice-021-safer-local-defaults-and-a-better-ui-23o9</guid>
      <description>&lt;p&gt;I first shared &lt;a href="https://dev.to/karmadon/balaka-local-only-tts-with-fastapi-and-a-separate-frontend-3hp"&gt;Balaka&lt;/a&gt; as a small, inspectable text-to-speech app that runs on your machine. Today I released &lt;a href="https://github.com/stremovskyy/balaka/releases/tag/v0.3.0" rel="noopener noreferrer"&gt;v0.3.0&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Balaka still has a FastAPI backend, a build-free browser interface, voice design, and voice cloning. Speech generation stays local; the model is downloaded only when it is not already cached.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OmniVoice 0.2.1:&lt;/strong&gt; Both synthesis modes now use the updated runtime. I verified real Ukrainian voice design and voice cloning as 24 kHz mono WAVs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local by default:&lt;/strong&gt; The server now listens on &lt;code&gt;127.0.0.1:8000&lt;/code&gt;. You can explicitly set &lt;code&gt;BALAKA_HOST&lt;/code&gt; and &lt;code&gt;BALAKA_PORT&lt;/code&gt; when another address is needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clear limits:&lt;/strong&gt; Reference audio is capped at 20 MiB, with HTTP &lt;code&gt;413&lt;/code&gt; for larger uploads. Only one generation runs at a time per process; a second request gets HTTP &lt;code&gt;503&lt;/code&gt; instead of competing for the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A friendlier UI:&lt;/strong&gt; Text tips explain supported phrasing and expressive tags. An API preview shows the exact form fields and a copyable cURL command. Advanced settings expose edge silence and fade controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproducible setup:&lt;/strong&gt; Dependencies are pinned, and the updated CI passed the expanded test suite on Python 3.13.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A note about the voice
&lt;/h2&gt;

&lt;p&gt;The default &lt;code&gt;k2-fsa/OmniVoice&lt;/code&gt; model and its published weights have not changed. The inference code has, so a generated voice can sound different from v0.2.0. Voice design also varies between runs. To compare results, keep the text, settings, and any cloning reference audio and transcript the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/stremovskyy/balaka/releases/tag/v0.3.0" rel="noopener noreferrer"&gt;v0.3.0 release&lt;/a&gt; has setup instructions and two short Ukrainian &lt;a href="https://github.com/stremovskyy/balaka/releases/download/v0.3.0/uk-design-v030.wav" rel="noopener noreferrer"&gt;voice-design&lt;/a&gt; and &lt;a href="https://github.com/stremovskyy/balaka/releases/download/v0.3.0/uk-clone-v030.wav" rel="noopener noreferrer"&gt;voice-clone&lt;/a&gt; WAV samples. The full source is on &lt;a href="https://github.com/stremovskyy/balaka" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you try it, I'd welcome feedback on Ukrainian pronunciation, voice cloning, and the API.&lt;/p&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>ai</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Balaka: local-only TTS with FastAPI and a separate frontend</title>
      <dc:creator>Anthony</dc:creator>
      <pubDate>Mon, 06 Apr 2026 14:26:28 +0000</pubDate>
      <link>https://dev.to/karmadon/balaka-local-only-tts-with-fastapi-and-a-separate-frontend-3hp</link>
      <guid>https://dev.to/karmadon/balaka-local-only-tts-with-fastapi-and-a-separate-frontend-3hp</guid>
      <description>&lt;h1&gt;
  
  
  Balaka
&lt;/h1&gt;

&lt;p&gt;Balaka is a local-only text-to-speech app built around OmniVoice.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (October 27, 2026):&lt;/strong&gt; &lt;a href="https://dev.to/karmadon/balaka-v030-omnivoice-021-safer-local-defaults-and-a-better-ui-23o9"&gt;Balaka v0.3.0 is out&lt;/a&gt;. It adds OmniVoice 0.2.1, safer local defaults, request limits, and new UI controls. See the &lt;a href="https://github.com/stremovskyy/balaka/releases/tag/v0.3.0" rel="noopener noreferrer"&gt;GitHub release&lt;/a&gt; for details and WAV samples.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;runs TTS locally&lt;/li&gt;
&lt;li&gt;exposes a FastAPI backend&lt;/li&gt;
&lt;li&gt;ships a separate static frontend&lt;/li&gt;
&lt;li&gt;supports voice design and voice cloning&lt;/li&gt;
&lt;li&gt;removes remote inference fallback&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;I wanted a minimal local TTS stack that is easy to run, easy to inspect, and does not hide the model behind a remote service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/stremovskyy/balaka" rel="noopener noreferrer"&gt;https://github.com/stremovskyy/balaka&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>python</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
