<?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: authagonal</title>
    <description>The latest articles on DEV Community by authagonal (@authagonal).</description>
    <link>https://dev.to/authagonal</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%2F3992598%2F3d9e4aed-7459-4f38-80bb-85cef9969f27.png</url>
      <title>DEV Community: authagonal</title>
      <link>https://dev.to/authagonal</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/authagonal"/>
    <language>en</language>
    <item>
      <title>Un BFF para muchos inquilinos, y la única petición que llega sin cookie</title>
      <dc:creator>authagonal</dc:creator>
      <pubDate>Fri, 31 Jul 2026 12:51:23 +0000</pubDate>
      <link>https://dev.to/authagonal/un-bff-para-muchos-inquilinos-y-la-unica-peticion-que-llega-sin-cookie-5gc8</link>
      <guid>https://dev.to/authagonal/un-bff-para-muchos-inquilinos-y-la-unica-peticion-que-llega-sin-cookie-5gc8</guid>
      <description>&lt;p&gt;A las aplicaciones de página única se les enseñó a guardar sus propios tokens. La aplicación ejecuta el flujo de OAuth en el navegador, recibe un token de acceso y normalmente también un token de actualización, los guarda en algún lugar de JavaScript y los adjunta a cada llamada a la API. Está bien documentado, es lo que muestran la mayoría de los tutoriales, y coloca tu credencial más longeva en el único sitio que no puedes defender: un entorno de ejecución que ejecuta de buena gana cualquier cosa que consiga llegar a la página. Una inyección exitosa, una dependencia comprometida en algún punto de tu build, y ya nadie necesita hacer phishing a nadie. Leen el token y se marchan, y sigue funcionando hasta que caduca.&lt;/p&gt;

&lt;p&gt;El patrón backend-for-frontend pone la credencial fuera de alcance. El navegador habla con un pequeño servidor que pertenece a tu aplicación, y ese servidor es el cliente confidencial de OAuth. Ejecuta el intercambio de código, guarda los tokens de acceso y de actualización, y no entrega al navegador más que una cookie opaca. Esto es una descripción de cómo construimos el nuestro, y de la única parte que resultó ser genuinamente interesante: hacer que sirviera a más de un inquilino.&lt;/p&gt;

&lt;h2&gt;
  
  
  Con qué se queda el navegador
&lt;/h2&gt;

&lt;p&gt;Una cookie llamada &lt;code&gt;__Host-agbff&lt;/code&gt;, marcada como &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;SameSite=Lax&lt;/code&gt;, con alcance a toda la ruta y sin caducidad, de modo que muere con la sesión del navegador. Su valor son 256 bits de aleatoriedad y nada más. No es un token, no se decodifica en nada, y robarla del cable no es algo que puedas hacerle a una cookie &lt;code&gt;__Host-&lt;/code&gt; sobre TLS.&lt;/p&gt;

&lt;p&gt;Todo lo real reside en el lado del servidor, en un registro de sesión dentro de una caché distribuida: el token de acceso, el token de actualización, el token de identidad, cuándo caduca el token de acceso y a qué inquilino pertenece la sesión. El inicio de sesión en sí es un flujo de código de autorización corriente con PKCE, ejecutado por un cliente confidencial que se autentica ante el endpoint de tokens con su secreto. El token de identidad se valida como es debido a la vuelta: emisor, audiencia, firma contra las claves publicadas, tiempo de vida, y luego una comparación en tiempo constante del nonce con el valor guardado antes de la redirección. Solo después de todo eso existe una sesión y se establece una cookie.&lt;/p&gt;

&lt;h2&gt;
  
  
  Una cabecera de la que se comprueba su existencia, y nada más
&lt;/h2&gt;

&lt;p&gt;El navegador llama al BFF para obtener su propia información de usuario y para alcanzar la API que hace de proxy, y esas llamadas llevan una cabecera personalizada, &lt;code&gt;x-authagonal-bff&lt;/code&gt;. Su valor es irrelevante. Su presencia es toda la comprobación.&lt;/p&gt;

&lt;p&gt;Parece perezoso pero no lo es. Toda la clase de falsificación de petición entre sitios se apoya en el envío de un formulario, una etiqueta de imagen o una navegación que otro origen puede desencadenar mientras tu cookie va de acompañante, y ninguna de esas cosas puede establecer una cabecera personalizada. En el momento en que un JavaScript atacante intenta añadir una, deja de ser una petición simple y pasa a ser una con verificación previa, que tu política de CORS rechaza. La cabecera no es un secreto que haya que adivinar, es la prueba de que la petición vino de código y no de marcado.&lt;/p&gt;

&lt;p&gt;Se exige en las llamadas hechas por script y deliberadamente no en las que por naturaleza son navegaciones de nivel superior: iniciar un inicio de sesión, volver del proveedor de identidad, seguir un enlace de cierre de sesión. Exigir una cabecera personalizada en una navegación del navegador no haría más que romper la navegación.&lt;/p&gt;

&lt;h2&gt;
  
  
  Actualizar, exactamente una vez
&lt;/h2&gt;

&lt;p&gt;Cada petición que pasa por el proxy puede descubrir que el token de acceso está a punto de caducar, y una página con mucha actividad hace varias peticiones a la vez. Actualízalas todas de forma ingenua y obtienes un pequeño desastre: varias actualizaciones simultáneas con un token de actualización rotatorio, cada una invalidando a las demás, terminando con el usuario desconectado por su propio tráfico.&lt;/p&gt;

&lt;p&gt;Por eso la actualización es de vuelo único, por sesión. La primera petición que pasa toma una compuerta indexada por el id de sesión, y todas las demás esperan. La parte sutil es lo que hacen las que esperan cuando por fin entran: releen la sesión desde el almacén antes de decidir nada, porque la petición que sostuvo la compuerta primero muy probablemente ya ha actualizado, y el valor que sostenían al ponerse en cola está obsoleto. Actualiza una vez, y luego todos usan el resultado.&lt;/p&gt;

&lt;p&gt;También es consciente de la rotación, en el sentido aburrido y necesario. Si el endpoint de tokens devuelve un nuevo token de actualización, este reemplaza al almacenado. Si la actualización falla con un error de token, la sesión se elimina y el usuario queda desconectado, porque un token de actualización que ha sido rechazado no va a ser aceptado en el siguiente intento, y reintentar no es más que una manera más lenta de quedar desconectado.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un BFF, muchos inquilinos
&lt;/h2&gt;

&lt;p&gt;Un BFF de un solo inquilino lo resuelve todo a partir de la configuración al arrancar: una autoridad, un id de cliente, un secreto. Servir a muchos inquilinos desde un mismo despliegue rompe eso, porque ahora el BFF tiene que averiguar a qué emisor pertenece un inicio de sesión, y tiene que hacerlo antes de que exista sesión alguna.&lt;/p&gt;

&lt;p&gt;La selección ocurre en el endpoint de inicio de sesión, mediante un parámetro de consulta que tú decides cómo llamar, así que puede ser &lt;code&gt;?slug=&lt;/code&gt; o &lt;code&gt;?org=&lt;/code&gt; o como ya llame tu producto a un cliente. Un resolvedor convierte esa clave en una configuración de inquilino: autoridad, id de cliente, secreto, ámbitos. Si no resuelve a nada, el inicio de sesión se rechaza en el acto.&lt;/p&gt;

&lt;p&gt;Luego la clave tiene que sobrevivir a un viaje de ida y vuelta hasta un proveedor de identidad que no sabe nada de ella, y regresar. Todavía no hay sesión que la conserve, así que viaja en la cookie de correlación, esa cookie cifrada de corta vida que ya lleva, por exactamente esta razón, el verificador PKCE, el estado y el nonce. Quince minutos, una por intento de inicio de sesión. A la vuelta la cookie se descifra, el inquilino se resuelve de nuevo a partir de la clave que contiene, y el código se intercambia contra el endpoint de tokens de ese inquilino. Solo entonces la clave de inquilino se escribe en la sesión, donde se convierte en la respuesta duradera.&lt;/p&gt;

&lt;p&gt;A partir de ahí, cada ruta vuelve a resolver desde la sesión en vez de desde cualquier cosa que dijera el navegador: actualización, cierre de sesión, el proxy. Y cuando un inquilino deja de resolver, porque fue eliminado o deshabilitado, la sesión se destruye en lugar de recurrir en silencio a algún inquilino por defecto. Un inquilino irresoluble es un cierre de sesión, no un encogimiento de hombros.&lt;/p&gt;

&lt;h2&gt;
  
  
  La petición que llega sin cookie
&lt;/h2&gt;

&lt;p&gt;El cierre de sesión back-channel es donde este diseño se gana el sueldo. El proveedor de identidad envía por POST un token de cierre de sesión directamente a tu servidor cuando una sesión termina en otro sitio. Sin cookie. Sin sesión. Sin navegador de por medio en absoluto. En un BFF de un solo inquilino esto no tiene nada de particular, ya que solo hay un emisor del que podría provenir. En uno multiinquilino tienes que responder «¿para qué inquilino es esto?» antes de poder responder «¿es siquiera auténtico?», y el único material disponible es el propio token, que no has verificado y por tanto no puedes confiar en él.&lt;/p&gt;

&lt;p&gt;El orden que hace esto seguro merece enunciarse con precisión. Lee el claim de emisor del token no verificado y úsalo para exactamente una cosa: elegir qué configuración de inquilino cargar. Luego verifica la firma del token contra las claves publicadas de ese inquilino y su id de cliente como audiencia. Un emisor falsificado elige el inquilino que finge ser, y luego falla la verificación contra las claves de ese inquilino, porque el atacante no las tiene. Nunca se aceptó nada sobre la base del claim no confiable. El claim solo decidió quién puede juzgarlo, y el juez es un ancla de confianza que ya teníamos.&lt;/p&gt;

&lt;p&gt;Después vienen las comprobaciones que hacen de un token de cierre de sesión un verdadero token de cierre de sesión: no debe llevar nonce, ya que no está autenticando a nadie; debe llevar el evento de cierre de sesión back-channel; y debe identificar o bien una sesión concreta, o bien un sujeto. Un id de sesión mata una sesión. Un sujeto mata todas las sesiones que tiene ese usuario, que es la forma que emitimos, porque «ciérrame la sesión en todas partes» es lo que la gente realmente quiere decir cuando pulsa cerrar sesión en todas partes. Por eso el almacén de sesiones mantiene un índice por id de sesión y otro por sujeto, en vez de solo por valor de cookie.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un solo inquilino sigue siendo aburrido
&lt;/h2&gt;

&lt;p&gt;Todo esto permanece inerte en el caso común. El resolvedor por defecto devuelve la misma configuración sin importar qué clave se le entregue, y su resolución por emisor devuelve esa configuración sin condiciones, porque solo hay una. Un BFF de un solo inquilino se comporta exactamente igual que antes de que la costura existiera: ningún parámetro de inquilino, nada extra en la cookie, ningún modo de fallo nuevo. La maquinaria multiinquilino es opcional e invisible hasta que nombras un parámetro de consulta.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separa la selección de la confianza
&lt;/h2&gt;

&lt;p&gt;Cuando conviertes algo en multiinquilino, el ejercicio que vale la pena hacer es enumerar cada punto de entrada y preguntar qué identifica al inquilino en cada uno. Las navegaciones del navegador llevan una cookie que tú emitiste y controlas. Las devoluciones de llamada de redirección llevan un estado que tú firmaste. Las devoluciones de llamada de servidor a servidor solo llevan lo que quien llama decidió enviar, y quien llama podría estar mintiendo.&lt;/p&gt;

&lt;p&gt;El punto de entrada incómodo, el que no lleva nada digno de confianza, es aquel cuyo diseño decide si todo el sistema es sólido, y suele ser el que nadie tiene en cuenta hasta tarde. El patrón que nos sacó adelante se generaliza: separa la selección de la confianza. No hay problema en enrutar según datos no confiables mientras el enrutamiento solo elija qué ancla de confianza tiene permitido tomar la decisión final, y la decisión final la tome algo que el atacante no puede falsificar.&lt;/p&gt;

&lt;p&gt;Si prefieres no escribir todo eso por tu cuenta, &lt;a href="https://authagonal.io/docs" rel="noopener noreferrer"&gt;Authagonal&lt;/a&gt; incluye el BFF para .NET y para Node, y el portal aprovisionará el cliente confidencial que necesita, URI de redirección, endpoint de cierre de sesión back-channel y todo lo demás, en un solo clic.&lt;/p&gt;

</description>
      <category>bff</category>
      <category>oidc</category>
      <category>auth</category>
      <category>security</category>
    </item>
    <item>
      <title>We proved every alert could fire. Most of them couldn't.</title>
      <dc:creator>authagonal</dc:creator>
      <pubDate>Fri, 31 Jul 2026 12:51:09 +0000</pubDate>
      <link>https://dev.to/authagonal/we-proved-every-alert-could-fire-most-of-them-couldnt-942</link>
      <guid>https://dev.to/authagonal/we-proved-every-alert-could-fire-most-of-them-couldnt-942</guid>
      <description>&lt;p&gt;Monitoring has a property that makes it uniquely dangerous: it is the code that only runs during a disaster. Every other part of your system runs constantly, in front of users, throwing errors you notice. Your alerting runs the day the database fills up, and not before, and if it is broken you find out at the exact moment you were relying on it and in the exact way that guarantees nobody was watching. A silent alert is worse than no alert, because no alert you know you do not have, and a silent one you believe you do.&lt;/p&gt;

&lt;p&gt;So we stopped trusting that our alerts worked because we had written them, and built a harness to prove it. For every alert rule we deploy, the harness drives a real signal that should trip it, and then it does the thing that actually matters: it waits for the notification to physically arrive in a capture relay, a small service standing in for the phone that would otherwise buzz. Firing in the monitoring tool's own UI does not count. Delivery counts. The full path from signal to a message in hand, or it is a failure.&lt;/p&gt;

&lt;p&gt;The first real run failed most of the rules. Here is what was hiding behind green dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flagship alert was dead
&lt;/h2&gt;

&lt;p&gt;The most important rule we own is the one that fires when the error rate climbs: too many HTTP 500s, page somebody. The harness drove four hundred and fifty real server errors at it, verified that at least ninety percent of them genuinely returned 500, and waited. Nothing arrived. The single alert we would most want to trust had never once fired, and would not have fired in a real incident.&lt;/p&gt;

&lt;p&gt;The cause was a name. Our metrics come out of OpenTelemetry, where names are dotted, like &lt;code&gt;http.server.request.duration&lt;/code&gt;. Somewhere between reading a tutorial and writing the query, we had assumed the monitoring backend would normalise those dots to underscores, the way a lot of Prometheus tooling does, and written the alert query against the underscore form. The backend does not normalise. It keeps the dotted names exactly as sent. So the alert queried a metric that did not exist, matched nothing, computed an error rate of zero forever, and sat there calm and green while the real metric, under its real dotted name, recorded every one of those five hundreds. The query was not wrong in a way that errors. It was wrong in a way that silently returns the empty set, which arithmetic then turns into a perfectly plausible zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that took every log alert down with it
&lt;/h2&gt;

&lt;p&gt;That was the bad one. The next one was worse, and the harness only caught it because it insists on delivery rather than on the rule looking correct.&lt;/p&gt;

&lt;p&gt;A whole category of our alerts is log-based: match a line in the logs, fire. Every single one of them was evaluating against nothing, and the reason had nothing to do with the alert rules. It was a retention setting.&lt;/p&gt;

&lt;p&gt;We reconcile our log retention through the backend's settings API. That API had moved to a new version with a new request shape, and we were still sending the old shape. The old body was not rejected. The decoder on the other end silently ignores fields it does not recognise, so it read our now-unknown fields as absent, took the default for the retention it could not find, and that default was zero. The call returned a cheerful 200. And a retention of zero days means instant expiry: newly written log data was being stamped to expire immediately, so the store the log alerts query was, for practical purposes, always empty. Ingestion looked completely healthy the whole time, because logs were arriving. They were just aging out the instant they landed.&lt;/p&gt;

&lt;p&gt;Two systems each did something defensible. The settings API accepted a request it partly did not understand rather than failing, which is a common and often reasonable choice. Our deploy sent a body that had gone stale against a version bump. Neither raised anything. The result was that the single most consequential number in our log pipeline, how long a log lives, had been quietly set to zero, and the only visible symptom was every log alert being permanently, healthily silent. If we had been testing that the rules looked right, they looked perfect. Only testing that a real logged event produces a delivered page could surface it, because only that test actually reads back out of the store that had been emptied.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alerts that fired forever, which is the same as never
&lt;/h2&gt;

&lt;p&gt;A third failure ran in the opposite direction, and it is the subtle one. A handful of audit alerts were firing constantly. That sounds like the opposite of a silent alert, but it has the identical effect, and understanding why is worth the detour.&lt;/p&gt;

&lt;p&gt;When one of these alerts fired, the monitoring backend wrote a log line about the evaluation, a breadcrumb, and that line contained the alert's own filter expression as text. That breadcrumb was itself ingested as a log, like anything else. So the next time the rule evaluated, it found a matching line: its own footprint from last time. The rule had become self-sustaining. It matched its own shadow, every evaluation, forever.&lt;/p&gt;

&lt;p&gt;An alert that is always firing never transitions from not-firing to firing, and that transition is the thing that sends a notification. So the alerting layer, seeing a condition that was already true and stayed true, suppressed further notifications, exactly as designed, to spare you a page every minute about a thing you have already been told. The consequence is that a real event, a genuine audit action worth paging about, arrived into a rule that was already stuck on and therefore said nothing. An alert pinned permanently on is as mute as one pinned permanently off. The fix was to scope those rules to log lines emitted by our own services, so they stop matching the backend's chatter about themselves, with one deliberate exception whose real signal genuinely comes from outside our services.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that treated a pod's first breath as normal
&lt;/h2&gt;

&lt;p&gt;The last one is a small, sharp lesson about rate calculations. An alert built on the rate of change of a metric cannot compute a rate from a series' very first sample, because there is nothing before it to compare against. So when a fresh pod emitted its first burst of errors, that burst became the baseline rather than a spike, and the rule that should have caught it saw only a starting point. We fixed it in the harness by driving such signals in two waves, a small one to establish the series and a real one after it, which is also a fair description of what production traffic does on its own and what a synthetic test has to imitate deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every failure hid in a seam you can only find by exercising it
&lt;/h2&gt;

&lt;p&gt;Every one of these was invisible to inspection. The queries read correctly. The rules were deployed. The dashboards were green. Ingestion was healthy. Each failure lived in a seam between two things that were each individually fine: a metric name and an assumption about normalisation, a request body and a version bump, an alert and the log its own firing produces, a rate function and a series that has just begun. You cannot read a seam. You have to exercise it.&lt;/p&gt;

&lt;p&gt;So treat monitoring like what it is, which is code, and specifically code whose only production run is the emergency. Code that only runs in the emergency needs its tests to run every other day, because there is no gentle first failure to warn you. Drive a real signal end to end, and assert on the artifact at the very end of the chain, the delivered notification, not on the rule looking healthy in the middle. Anything short of that is testing that you wrote an alert, which you already knew, rather than testing that it fires, which is the only thing you actually wanted.&lt;/p&gt;

&lt;p&gt;If you would rather your identity provider was already watching itself this way, &lt;a href="https://authagonal.io" rel="noopener noreferrer"&gt;Authagonal&lt;/a&gt; runs its own alerting through exactly this harness, so the day something breaks, the page that should fire actually does.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Die SCIM-Steuer: Provisioning ist Infrastruktur, kein Premium-Feature</title>
      <dc:creator>authagonal</dc:creator>
      <pubDate>Thu, 30 Jul 2026 01:39:09 +0000</pubDate>
      <link>https://dev.to/authagonal/die-scim-steuer-provisioning-ist-infrastruktur-kein-premium-feature-29c4</link>
      <guid>https://dev.to/authagonal/die-scim-steuer-provisioning-ist-infrastruktur-kein-premium-feature-29c4</guid>
      <description>&lt;p&gt;SCIM ist das unglamouröseste Protokoll der Identity-Welt. Es ist eine REST-API für Benutzerdatensätze: anlegen, aktualisieren, deaktivieren, standardisiert als RFC 7644, damit Verzeichnisse und Anwendungen sie nur ein einziges Mal bauen müssen. Wenn das IT-Team Ihres Kunden sein Entra- oder Okta-Verzeichnis mit Ihrem Produkt verbindet, sorgt SCIM dafür, dass neue Mitarbeiter am ersten Tag in Ihrer App auftauchen und Abgänger an ihrem letzten Tag verschwinden. Es ist Infrastruktur im buchstäblichsten Sinn: läuft unbeaufsichtigt, bleibt unsichtbar und fällt erst auf, wenn sie fehlt.&lt;/p&gt;

&lt;p&gt;Der übliche Preis für diese Infrastruktur ist bemerkenswert. WorkOS verlangt $125 im Monat pro Directory-Sync-Verbindung, zusätzlich zu den $125 im Monat pro SSO-Verbindung. Ein SaaS mit zehn Enterprise-Kunden, die jeweils das Standardpaar wollen, Login plus Provisioning, zahlt $2.500 im Monat, bevor sich auch nur einer ihrer Benutzer anmeldet. Auth0 spielt den anderen Klassiker: Der Free-Tarif enthält technisch gesehen eine Enterprise-Verbindung, aber die bezahlten Consumer-Tarife entfernen die Enterprise-Features wieder und lenken jeden, der sie wirklich braucht, auf B2B-Pläne, wo Verbindungen als Add-ons rund $100 pro Stück kosten. Egal in welcher Form, die Botschaft ist dieselbe: Provisioning lebt hinter dem Enterprise-Gatter, neben SAML, bepreist pro Verbindung.&lt;/p&gt;

&lt;p&gt;Es lohnt sich also zu fragen, was eine SCIM-Verbindung den Anbieter im Betrieb kostet. Auf Anbieterseite ist eine Verbindung ein Bearer-Token und eine Basis-URL. Der Traffic ist ein Rinnsal: eine HTTP-Anfrage, wenn jemand anfängt, eine, wenn er das Team oder den Namen wechselt, eine, wenn er geht. Kein Fan-out, keine nennenswerte Rechenlast, keine Storage-Geschichte. Die Spezifikation existiert genau deshalb, damit die Implementierung eine einmalige Investition ist: Wer &lt;code&gt;/Users&lt;/code&gt; und &lt;code&gt;/Groups&lt;/code&gt; mit PATCH-Semantik einmal gebaut hat, für den kostet Verbindung Nummer zweihundert exakt dasselbe wie Verbindung Nummer zwei, nämlich eine Zeile in einer Tabelle. $125 im Monat pro Verbindung ist keine Kostendeckung. Es ist ein Segmentierungszaun, aufgestellt dort, wo die Enterprise-Käufer stehen, weil Enterprise-Käufer zahlen können. Wir haben &lt;a href="https://authagonal.io/blog/the-sso-tax-in-dollars" rel="noopener noreferrer"&gt;diese Rechnung für SSO bereits in Dollar aufgemacht&lt;/a&gt;; SCIM ist dieselbe Steuer, kassiert an einer zweiten Mautstelle.&lt;/p&gt;

&lt;p&gt;Aber SCIM ist nicht SSO, und der Unterschied liegt darin, was passiert, wenn man nicht zahlt. Wer die SSO-Gebühr ablehnt, bekommt schlechtere Logins: mehr Passwörter, mehr Phishing-Angriffsfläche, eine genervte IT-Abteilung. Lästig, aber überlebbar. Wer die SCIM-Gebühr ablehnt, bei dem bricht das Offboarding. Die wichtigste Nachricht, die SCIM je überbringt, ist die, die sagt: Diese Person hat das Unternehmen verlassen, deaktiviert sie überall, sofort. Ohne sie besteht Deprovisioning darin, dass ein Mensch daran denken muss, sich am Tag, an dem jemand hinausbegleitet wird, durch die Admin-Konsole jedes einzelnen SaaS-Produkts der Firma zu klicken, ohne einen Schritt zu vergessen. In der Praxis heißt das: Ehemalige behalten tagelang funktionierende Konten, manchmal monatelang. Auditoren fragen in jedem SOC-2- und ISO-27001-Review aus gutem Grund nach zeitnahem Deprovisioning: Das schlafende Konto eines ausgeschiedenen Mitarbeiters, Passwort unverändert, ist eines der ältesten Einfallstore für Sicherheitsvorfälle überhaupt.&lt;/p&gt;

&lt;p&gt;Deprovisioning ist also kein Komfort-Feature. Es ist eine Sicherheitskontrolle, und eine Gebühr pro Verbindung auf SCIM ist ein Preisschild auf dieser Kontrolle. Und man beachte, wohin das Risiko wandert, wenn ein Käufer die Gebühr ablehnt: nirgendwohin auf Anbieterseite. Der Anbieter liefert exakt dasselbe Produkt. Die Lücke öffnet sich beim Kunden, in der Offboarding-Checkliste, die jetzt von Gedächtnis statt von Automatisierung abhängt. Für SCIM Geld zu verlangen heißt, Geld für das Schloss an einer Tür zu verlangen, die man bereits verkauft hat.&lt;/p&gt;

&lt;p&gt;Es gibt eine einfache Art, die Preisliste jedes Anbieters zu lesen: Was echtes Geld im Betrieb kostet, wird nach Nutzung bepreist, und was nichts kostet, wird danach bepreist, was man ablehnen kann. Preise pro Benutzer oder pro MAU folgen den tatsächlichen Kosten. Eine Gebühr pro Verbindung auf ein standardisiertes Protokoll folgt dem Hebel, und SCIM trägt den größten Hebel von allen, weil Enterprise-Sicherheitsfragebögen es vorschreiben. Der Käufer kann nicht Nein sagen, und der Preis ist entsprechend gesetzt. Das ist die SCIM-Steuer: keine Gebühr für eine Leistung, sondern ein Wegezoll auf eine Compliance-Anforderung.&lt;/p&gt;

&lt;p&gt;Wir halten die Mautstelle für das falsche Geschäftsmodell. &lt;a href="https://authagonal.io" rel="noopener noreferrer"&gt;Authagonal&lt;/a&gt; enthält SCIM in jedem Tarif, den Free-Tarif eingeschlossen, mit unbegrenzten Verbindungen, genau wie SSO. Provisioning ist Infrastruktur. Wir berechnen das, was uns tatsächlich etwas kostet, nämlich aktive Benutzer, und der Schalter, der das Konto eines Abgängers überall abschaltet, ist kein Premium. Er ist der Job.&lt;/p&gt;

</description>
      <category>scim</category>
      <category>pricing</category>
      <category>ssotax</category>
      <category>provisioning</category>
    </item>
    <item>
      <title>La taxe SCIM : le provisionnement, c'est de la plomberie, pas du premium</title>
      <dc:creator>authagonal</dc:creator>
      <pubDate>Thu, 30 Jul 2026 01:39:05 +0000</pubDate>
      <link>https://dev.to/authagonal/la-taxe-scim-le-provisionnement-cest-de-la-plomberie-pas-du-premium-3i7o</link>
      <guid>https://dev.to/authagonal/la-taxe-scim-le-provisionnement-cest-de-la-plomberie-pas-du-premium-3i7o</guid>
      <description>&lt;p&gt;SCIM est le protocole le moins glamour de l'identité. C'est une API REST pour les fiches utilisateurs : créer, mettre à jour, désactiver, standardisée sous le nom de RFC 7644 pour que les annuaires et les applications n'aient à la construire qu'une seule fois. Quand l'équipe IT de votre client connecte son annuaire Entra ou Okta à votre produit, c'est SCIM qui fait apparaître les nouvelles recrues dans votre application dès le premier jour et disparaître les partants le jour de leur départ. C'est de la plomberie au sens le plus littéral : sans surveillance, invisible, et remarquée seulement quand elle manque.&lt;/p&gt;

&lt;p&gt;Le tarif en vigueur pour cette plomberie est remarquable. WorkOS facture 125 $ par mois et par connexion Directory Sync, en plus des 125 $ par mois facturés par connexion SSO. Un SaaS avec dix clients entreprise qui veulent chacun la paire standard, connexion plus provisionnement, paie 2 500 $ par mois avant même qu'un seul de leurs utilisateurs ne se connecte. Auth0 joue l'autre grand classique : l'offre gratuite inclut techniquement une connexion entreprise, mais les offres grand public payantes retirent les fonctionnalités entreprise, poussant quiconque en a réellement besoin vers des plans B2B où les connexions sont des options à environ 100 $ pièce. Quelle que soit la forme, le message est le même : le provisionnement vit derrière la barrière entreprise, à côté de SAML, facturé à la connexion.&lt;/p&gt;

&lt;p&gt;Il vaut donc la peine de se demander ce qu'une connexion SCIM coûte au fournisseur. Côté fournisseur, une connexion, c'est un jeton bearer et une URL de base. Le trafic est un filet d'eau : une requête HTTP quand quelqu'un arrive, une quand il change d'équipe ou de nom, une quand il part. Pas de fan-out, pas de calcul digne d'être mentionné, pas d'enjeu de stockage. La spécification existe précisément pour que l'implémentation soit un coût unique : une fois que vous avez construit &lt;code&gt;/Users&lt;/code&gt; et &lt;code&gt;/Groups&lt;/code&gt; avec la sémantique PATCH, la connexion numéro deux cents coûte exactement ce qu'a coûté la connexion numéro deux, c'est-à-dire une ligne dans une table. 125 $ par mois et par connexion, ce n'est pas de la couverture de coûts. C'est une clôture de segmentation, placée là où se tiennent les acheteurs entreprise parce que les acheteurs entreprise peuvent payer. Nous avons déjà &lt;a href="https://authagonal.io/blog/the-sso-tax-in-dollars" rel="noopener noreferrer"&gt;chiffré cette facture en dollars pour le SSO&lt;/a&gt; ; SCIM est la même taxe, collectée à un deuxième péage.&lt;/p&gt;

&lt;p&gt;Mais SCIM n'est pas SSO, et la différence, c'est ce qui se passe quand vous ne payez pas. Refusez les frais SSO et les connexions se dégradent : plus de mots de passe, plus de surface de phishing, un service informatique agacé. Gênant, mais survivable. Refusez les frais SCIM et l'offboarding casse. Le message le plus important que SCIM transporte est celui qui dit : cette personne a quitté l'entreprise, désactivez-la partout, maintenant. Sans lui, le déprovisionnement, c'est un humain qui doit penser à cliquer dans la console d'administration de chaque produit SaaS utilisé par l'entreprise, le jour où quelqu'un est raccompagné à la porte, sans oublier une seule étape. En pratique, cela veut dire que des ex-salariés gardent des comptes actifs pendant des jours, parfois des mois. Si les auditeurs posent la question du déprovisionnement rapide dans chaque revue SOC 2 et ISO 27001, ce n'est pas pour rien : le compte dormant d'un salarié parti, mot de passe inchangé, est l'un des plus vieux points d'entrée de compromission qui soient.&lt;/p&gt;

&lt;p&gt;Ce qui veut dire que le déprovisionnement n'est pas une fonctionnalité de confort. C'est un contrôle de sécurité, et des frais par connexion sur SCIM, c'est une étiquette de prix sur ce contrôle. Regardez où va le risque quand un acheteur refuse de payer : nulle part côté fournisseur. Le fournisseur sert exactement le même produit. Le trou s'ouvre côté client, dans la checklist d'offboarding qui dépend désormais de la mémoire au lieu de l'automatisation. Faire payer SCIM, c'est faire payer le verrou d'une porte que vous avez déjà vendue.&lt;/p&gt;

&lt;p&gt;Il existe une façon simple de lire la grille tarifaire de n'importe quel fournisseur : ce qui coûte vraiment de l'argent à servir est facturé à l'usage, et ce qui ne coûte rien est facturé selon ce que vous pouvez refuser. La tarification par utilisateur ou par MAU suit le coût réel. Des frais par connexion sur un protocole standardisé suivent le rapport de force, et SCIM porte le plus grand rapport de force de tous, parce que les questionnaires de sécurité des entreprises l'exigent. L'acheteur ne peut pas dire non, et le prix est fixé en conséquence. C'est ça, la taxe SCIM : pas le prix d'un service, mais un péage sur une exigence de conformité.&lt;/p&gt;

&lt;p&gt;Nous pensons que le péage n'est pas le bon métier. &lt;a href="https://authagonal.io" rel="noopener noreferrer"&gt;Authagonal&lt;/a&gt; inclut SCIM dans chaque offre, y compris l'offre gratuite, avec des connexions illimitées, comme pour le SSO. Le provisionnement, c'est de la plomberie. Nous facturons ce qui nous coûte réellement quelque chose, à savoir les utilisateurs actifs, et l'interrupteur qui désactive partout le compte d'un partant n'est pas du premium. C'est le travail.&lt;/p&gt;

</description>
      <category>scim</category>
      <category>pricing</category>
      <category>ssotax</category>
      <category>provisioning</category>
    </item>
    <item>
      <title>We moved our images to a CDN and the origin kept serving them</title>
      <dc:creator>authagonal</dc:creator>
      <pubDate>Thu, 30 Jul 2026 01:24:51 +0000</pubDate>
      <link>https://dev.to/authagonal/we-moved-our-images-to-a-cdn-and-the-origin-kept-serving-them-27ia</link>
      <guid>https://dev.to/authagonal/we-moved-our-images-to-a-cdn-and-the-origin-kept-serving-them-27ia</guid>
      <description>&lt;p&gt;Our documentation is heavy with screenshots. Every portal feature, captured in light and dark, at two widths, across ten locales, which adds up to something north of a hundred megabytes of images. All of it was sitting in the app's own static bundle, served from the origin, which is the wrong place for a hundred megabytes of pictures that never change. So we moved it to Cloudflare R2 and put a CDN in front, one per environment: &lt;code&gt;cdn.authagonal.io&lt;/code&gt; for production and a matching CDN host for our dev site.&lt;/p&gt;

&lt;p&gt;The app picks the right CDN host at runtime by looking at the hostname it is being served from. If the page is on &lt;code&gt;authagonal.io&lt;/code&gt;, images come from &lt;code&gt;cdn.authagonal.io&lt;/code&gt;. Simple, no build-time configuration, one image reference that resolves correctly on either environment. We shipped it, watched the browser pull every image cleanly from the CDN, and then looked at the origin's traffic. The origin was still serving the images. Every one, on first load, exactly as before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The page a crawler sees is not the page a browser builds
&lt;/h2&gt;

&lt;p&gt;To explain why, I have to explain what our marketing pages actually are. They are a single-page app, but a single-page app is a blank shell until JavaScript runs, and a blank shell is bad for search engines and bad for the time it takes the first meaningful thing to appear. So at build time we prerender: we start the site, open each page in a headless browser, let it render fully, and save the resulting HTML. That prerendered HTML is what we serve first. It already has the content in it, the crawler sees a real page, the user sees text and images immediately, and then the JavaScript takes over.&lt;/p&gt;

&lt;p&gt;Here is the problem hiding in that description. The prerender runs the site on &lt;code&gt;127.0.0.1&lt;/code&gt;, a loopback address on the build machine. So when the app asks "what hostname am I on, so I can pick a CDN," the answer during prerender is "a local IP address," which matches neither &lt;code&gt;authagonal.io&lt;/code&gt; nor the dev host. The runtime host-derivation, the clever part, quietly falls through to its only remaining option: the origin-relative path. And that origin path is what gets frozen into the prerendered HTML.&lt;/p&gt;

&lt;p&gt;So every visitor received HTML with origin image URLs baked in, and the browser did the obvious thing: it fetched them, from the origin, on first paint. A moment later the JavaScript re-rendered the page, re-derived the hostname, this time getting the real one, and swapped the images to the CDN. Two fetches for every image, the first one hitting exactly the server we were trying to spare, and completely invisible unless you were watching the origin's logs, because the page looked and worked perfectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that made things worse for eleven minutes
&lt;/h2&gt;

&lt;p&gt;The first fix was the tempting one. If the problem is that a wrong URL gets frozen into the prerendered HTML, then emit no URL during prerender at all. Leave the image source blank in the snapshot, and let the JavaScript fill in the correct CDN URL once it runs and knows the real hostname. No wrong URL, no origin fetch.&lt;/p&gt;

&lt;p&gt;It shipped, and it was wrong, and we replaced it about eleven minutes later. Blanking the image source in the prerendered HTML defeats the entire reason we prerender. The crawler that reads the static page now sees no image, and the browser has nothing to show until JavaScript has run, which is the slow path we build the prerendered HTML specifically to avoid. We had removed the double fetch by removing the image from the one version of the page that exists before JavaScript. That trades a traffic problem for a search-and-first-paint problem, which is a worse trade.&lt;/p&gt;

&lt;p&gt;The lesson from those eleven minutes: the prerendered HTML has to carry a real, working image reference. The bug was never that a URL was present. It was that the wrong URL was present, and blanking it just swaps one defect for another. What we actually needed was for the correct URL to be knowable at prerender time, and it is not, because at prerender time nobody knows which environment will serve the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deciding at the last possible moment
&lt;/h2&gt;

&lt;p&gt;The correct URL depends on the request host, and the request host is not known until there is a request. Prerender happens once, at build, and produces one file that is served on both environments. So the environment-specific part of the URL cannot be decided at build. It has to be decided per request, and the only thing in the stack that sees every request and its host is the web server at the edge.&lt;/p&gt;

&lt;p&gt;So the prerender no longer writes a URL or a blank. It writes a placeholder, a literal sentinel string, &lt;code&gt;__ASSET_CDN__&lt;/code&gt;, in place of the CDN host. The prerendered HTML that leaves the build says &lt;code&gt;__ASSET_CDN__/screenshots/whatever.webp&lt;/code&gt;, which is neither an origin URL nor an empty source. It is a URL with a hole in it.&lt;/p&gt;

&lt;p&gt;nginx fills the hole on the way out. It maps the request's host to the right CDN base, the non-production host to its own dev CDN, &lt;code&gt;authagonal.io&lt;/code&gt; to &lt;code&gt;https://cdn.authagonal.io&lt;/code&gt;, anything else to empty so the URL collapses back to origin-relative as a safe local fallback. Then a single directive rewrites the sentinel to that value as the HTML streams to the client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt; &lt;span class="nv"&gt;$asset_cdn_base&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt;              &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;"~*example\.dev&lt;/span&gt;$&lt;span class="s"&gt;"&lt;/span&gt;    &lt;span class="s"&gt;"https://cdn.example.dev"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;"~*authagonal\.io&lt;/span&gt;$&lt;span class="s"&gt;"&lt;/span&gt;  &lt;span class="s"&gt;"https://cdn.authagonal.io"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;sub_filter&lt;/span&gt; &lt;span class="s"&gt;'__ASSET_CDN__'&lt;/span&gt; &lt;span class="nv"&gt;$asset_cdn_base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The static HTML now leaves the origin already carrying real CDN URLs, correct for whichever host asked. The browser fetches straight from the CDN on first paint. There is no wrong first fetch to undo, because there is no wrong URL, and there was never a blank one either. The crawler sees a complete page with real image links. One file serves both environments, and neither one needs a separate build.&lt;/p&gt;

&lt;p&gt;There is a small elegance to which parts of the response get rewritten. The rewrite is scoped to HTML only, which is nginx's default. That matters because the same sentinel string appears in the JavaScript bundle too, as the fallback branch of the host-derivation code. On the client that branch is dead code, since the browser has a real hostname and never emits the sentinel, so we specifically do not want nginx touching the bundle. Leaving the rewrite at its HTML-only default gets that for free. We learned, incidentally, that writing the default out explicitly is worse than leaving it off, because a redundant declaration makes nginx warn about a duplicate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just rewrite the file once on startup
&lt;/h2&gt;

&lt;p&gt;The obvious objection is that this is a lot of per-request work for a value that only has two possibilities. Why not rewrite the HTML once when the container boots, bake in the right host, and serve a static file with no filter?&lt;/p&gt;

&lt;p&gt;Because the container cannot write to itself. The static site runs in nginx as a non-root user with a read-only root filesystem and every Linux capability dropped, with a single small writable scratch directory for nginx's own temp files. That is a deliberate hardening posture: a web server that serves untrusted traffic should not be able to modify the files it serves, so that an attacker who finds a foothold finds nothing to rewrite. A boot-time rewrite of the HTML would need exactly the write access we took away on purpose. The read-only filesystem is not an obstacle to work around, it is a property we want, and it rules out the boot-time rewrite cleanly. The per-request rewrite touches no files at all, which is why it is compatible with a server that owns nothing it can change.&lt;/p&gt;

&lt;p&gt;We also did not want to bake the CDN host in at build and produce two different image bundles, one per environment, because that reintroduces the environment-specific build we had just gotten rid of. The point of host-derivation was one artifact for both environments. The sentinel keeps that promise: one build, one file, the environment resolved at the edge at the last possible moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerendering freezes environment values, so resolve them at the edge
&lt;/h2&gt;

&lt;p&gt;Prerendering freezes a moment. Anything your app decides by looking at its environment gets decided, at prerender time, against the &lt;em&gt;build's&lt;/em&gt; environment, which is a loopback address and nobody's real host. So a value that is correct at runtime becomes wrong the instant it is snapshotted, and it fails silently, because the prerendered page still renders, just pointing at the wrong place.&lt;/p&gt;

&lt;p&gt;The general fix is to not resolve environment-specific values during prerender at all. Emit a placeholder, carry it through the static artifact intact, and resolve it at the layer that actually sees the environment, which for a per-request value is the edge. It is late binding, applied to a string in an HTML file: leave the hole open through every stage that does not know the answer, and fill it at the one stage that does.&lt;/p&gt;

&lt;p&gt;If you would rather your identity provider's own documentation and assets were already served correctly off the edge, that is one of the many small things &lt;a href="https://authagonal.io" rel="noopener noreferrer"&gt;Authagonal&lt;/a&gt; has already argued out so you can spend the argument on your own product instead.&lt;/p&gt;

</description>
      <category>cloud</category>
      <category>infrastructure</category>
      <category>performance</category>
    </item>
    <item>
      <title>A BFF for many tenants, and the one request that arrives with no cookie</title>
      <dc:creator>authagonal</dc:creator>
      <pubDate>Tue, 28 Jul 2026 10:05:53 +0000</pubDate>
      <link>https://dev.to/authagonal/a-bff-for-many-tenants-and-the-one-request-that-arrives-with-no-cookie-2dl9</link>
      <guid>https://dev.to/authagonal/a-bff-for-many-tenants-and-the-one-request-that-arrives-with-no-cookie-2dl9</guid>
      <description>&lt;p&gt;Single-page apps were taught to hold their own tokens. The app runs the OAuth flow in the browser, gets an access token and usually a refresh token back, keeps them somewhere in JavaScript, and attaches them to every API call. It is well documented, it is what most tutorials show, and it puts your longest-lived credential in the one place you cannot defend: a runtime that will cheerfully execute anything that finds its way onto the page. One successful injection, one compromised dependency somewhere in your build, and nobody needs to phish anybody. They read the token and leave, and it keeps working until it expires.&lt;/p&gt;

&lt;p&gt;The backend-for-frontend pattern moves the credential out of reach. The browser talks to a small server that belongs to your app, and that server is the confidential OAuth client. It runs the code exchange, it holds the access and refresh tokens, and it hands the browser nothing but an opaque cookie. This is a description of how we built ours, and of the one part that turned out to be genuinely interesting: making it serve more than one tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the browser ends up with
&lt;/h2&gt;

&lt;p&gt;A cookie called &lt;code&gt;__Host-agbff&lt;/code&gt;, marked &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;SameSite=Lax&lt;/code&gt;, scoped to the whole path, with no expiry so it dies with the browser session. Its value is 256 bits of randomness and nothing else. It is not a token, it does not decode into anything, and stealing it off the wire is not a thing you can do to a &lt;code&gt;__Host-&lt;/code&gt; cookie over TLS.&lt;/p&gt;

&lt;p&gt;Everything real sits server-side, in a session record in a distributed cache: the access token, the refresh token, the id token, when the access token expires, and which tenant the session belongs to. The login itself is an ordinary authorization code flow with PKCE, run by a confidential client that authenticates to the token endpoint with its secret. The id token is validated properly on the way back in, issuer, audience, signature against the published keys, lifetime, and then a constant-time comparison of the nonce against the value stashed before the redirect. Only after all of that does a session exist and a cookie get set.&lt;/p&gt;

&lt;h2&gt;
  
  
  A header that is checked for existence, and nothing else
&lt;/h2&gt;

&lt;p&gt;The browser calls the BFF for its own user info and to reach the proxied API, and those calls carry a custom header, &lt;code&gt;x-authagonal-bff&lt;/code&gt;. Its value is irrelevant. Presence is the whole check.&lt;/p&gt;

&lt;p&gt;That looks lazy and is not. The entire class of cross-site request forgery relies on a form post, an image tag, or a navigation that another origin can trigger while your cookie rides along, and none of those can set a custom header. The moment attacking JavaScript tries to add one it stops being a simple request and becomes a preflighted one, which your CORS policy declines. The header is not a secret to be guessed, it is proof that the request came from code rather than from markup.&lt;/p&gt;

&lt;p&gt;It is required on the scripted calls and deliberately not on the ones that are top-level navigations by nature: starting a login, coming back from the identity provider, following a logout link. Demanding a custom header on a browser navigation would just break the navigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refresh, exactly once
&lt;/h2&gt;

&lt;p&gt;Every proxied request may discover that the access token is about to expire, and a busy page makes several requests at once. Refresh them all naively and you get a small disaster: several concurrent refreshes with a rotating refresh token, each one invalidating the others, ending with the user logged out by their own traffic.&lt;/p&gt;

&lt;p&gt;So refresh is single-flight, per session. The first request through takes a gate keyed by the session id and everyone else waits. The subtle part is what the waiters do when they get in: they re-read the session from the store before deciding anything, because the request that held the gate first has very likely already refreshed, and the value they were holding when they queued is stale. Refresh once, then everybody uses the result.&lt;/p&gt;

&lt;p&gt;It is also rotation-aware in the boring, necessary sense. If the token endpoint hands back a new refresh token, it replaces the stored one. If refresh fails with a token error, the session is deleted and the user is logged out, because a refresh token that has been rejected is not going to be accepted on the next attempt, and retrying is just a slower way to be logged out.&lt;/p&gt;

&lt;h2&gt;
  
  
  One BFF, many tenants
&lt;/h2&gt;

&lt;p&gt;A single-tenant BFF resolves everything from configuration at startup: one authority, one client id, one secret. Serving many tenants from one deployment breaks that, because now the BFF has to work out which issuer a login belongs to, and it has to do so before any session exists.&lt;/p&gt;

&lt;p&gt;Selection happens at the login endpoint, through a query parameter you get to name, so it can be &lt;code&gt;?slug=&lt;/code&gt; or &lt;code&gt;?org=&lt;/code&gt; or whatever your product already calls a customer. A resolver turns that key into a tenant config: authority, client id, secret, scopes. If it resolves to nothing, the login is refused there and then.&lt;/p&gt;

&lt;p&gt;Then the key has to survive a round trip to an identity provider that knows nothing about it, and come back. There is no session yet to hold it, so it rides in the correlation cookie, the short-lived encrypted cookie that already carries the PKCE verifier, the state, and the nonce for exactly this reason. Fifteen minutes, one per login attempt. On the way back the cookie is decrypted, the tenant is resolved again from the key inside it, and the code is exchanged against that tenant's token endpoint. Only then is the tenant key written into the session, where it becomes the durable answer.&lt;/p&gt;

&lt;p&gt;From that point every path re-resolves from the session rather than from anything the browser said: refresh, logout, the proxy. And when a tenant stops resolving, because it was deleted or disabled, the session is destroyed rather than quietly falling back to some default tenant. An unresolvable tenant is a logout, not a shrug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request that arrives with no cookie
&lt;/h2&gt;

&lt;p&gt;Back-channel logout is where this design earns its keep. The identity provider POSTs a logout token straight to your server when a session ends elsewhere. No cookie. No session. No browser involved at all. In a single-tenant BFF that is unremarkable, since there is only one issuer it could be from. In a multi-tenant one you have to answer "which tenant is this for?" before you can answer "is this even real?", and the only material available is the token itself, which you have not verified and therefore cannot trust.&lt;/p&gt;

&lt;p&gt;The ordering that makes this safe is worth stating precisely. Read the issuer claim out of the unverified token, and use it for exactly one thing: choosing which tenant's configuration to load. Then verify the token's signature against that tenant's published keys and its client id as the audience. A forged issuer picks the tenant it is pretending to be, and then fails verification against that tenant's keys, because the attacker does not have them. Nothing was ever accepted on the strength of the untrusted claim. The claim only decided who gets to judge it, and the judge is a trust anchor we already had.&lt;/p&gt;

&lt;p&gt;After that come the checks that make a logout token a logout token: it must not carry a nonce, since it is not authenticating anybody; it must carry the back-channel logout event; and it must identify either a specific session or a subject. A session id kills one session. A subject kills every session that user has, which is the form we emit, because "log me out everywhere" is what people actually mean when they click log out everywhere. That is why the session store keeps an index by session id and another by subject, rather than only by cookie value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Single tenant stays boring
&lt;/h2&gt;

&lt;p&gt;All of this is inert for the common case. The default resolver returns the same configuration no matter what key it is handed, and its resolve-by-issuer returns that configuration unconditionally, because there is only one. A single-tenant BFF behaves exactly as it did before the seam existed: no tenant parameter, nothing extra in the cookie, no new failure modes. The multi-tenant machinery is opt-in and invisible until you name a query parameter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate selection from trust
&lt;/h2&gt;

&lt;p&gt;When you make something multi-tenant, the exercise worth doing is to enumerate every entry point and ask what identifies the tenant on each one. Browser navigations carry a cookie you issued and control. Redirect callbacks carry state you signed. Server-to-server callbacks carry only what the caller chose to send, and the caller might be lying.&lt;/p&gt;

&lt;p&gt;The awkward entry point, the one with nothing trustworthy on it, is the one whose design decides whether the whole system is sound, and it is usually the one nobody thinks about until late. The pattern that got us through it generalises: separate selection from trust. It is fine to route on untrusted data as long as routing only chooses which trust anchor is allowed to make the final call, and the final call is made by something the attacker cannot forge.&lt;/p&gt;

&lt;p&gt;If you would rather not write all of that yourself, &lt;a href="https://authagonal.io/docs" rel="noopener noreferrer"&gt;Authagonal&lt;/a&gt; ships the BFF for .NET and for Node, and the portal will provision the confidential client it needs, redirect URI, back-channel logout endpoint and all, in one click.&lt;/p&gt;

</description>
      <category>bff</category>
      <category>oidc</category>
      <category>auth</category>
      <category>security</category>
    </item>
    <item>
      <title>Votre sauvegarde ressuscite en silence les utilisateurs que vous avez supprimés</title>
      <dc:creator>authagonal</dc:creator>
      <pubDate>Mon, 27 Jul 2026 03:48:22 +0000</pubDate>
      <link>https://dev.to/authagonal/votre-sauvegarde-ressuscite-en-silence-les-utilisateurs-que-vous-avez-supprimes-12p6</link>
      <guid>https://dev.to/authagonal/votre-sauvegarde-ressuscite-en-silence-les-utilisateurs-que-vous-avez-supprimes-12p6</guid>
      <description>&lt;p&gt;Toute sauvegarde incrémentale repose sur la même hypothèse silencieuse : pour sauvegarder ce qui a changé, il faut trouver les lignes qui ont changé. Sur un magasin clé-valeur comme Azure Table Storage ou DynamoDB, « ce qui a changé » signifie « les lignes dont l'horodatage de dernière modification est plus récent que mon dernier repère ». On parcourt la table, on prend tout ce qui est plus récent, on l'écrit. Rapide, pas cher, correct.&lt;/p&gt;

&lt;p&gt;Correct pour les écritures. Maintenant, supprimez une ligne.&lt;/p&gt;

&lt;p&gt;La ligne ne reçoit pas d'indicateur « supprimé ». Elle ne reçoit pas d'horodatage plus récent. Elle ne part pas dans une corbeille. Elle cesse simplement d'exister. Ces magasins n'ont ni marqueurs de suppression ni flux de changements pour les suppressions, donc une ligne supprimée ne laisse derrière elle exactement rien. Votre sauvegarde incrémentale, qui repère les changements en cherchant des horodatages plus récents, passe tout droit devant l'espace vide où la ligne se trouvait et ne trouve rien. Il n'y a rien à trouver. La suppression est invisible.&lt;/p&gt;

&lt;p&gt;Ce qui veut dire que votre sauvegarde contient toujours la ligne. Et c'est là qu'une histoire de perte de données devient une histoire de sécurité.&lt;/p&gt;

&lt;p&gt;Pensez à ce qu'une suppression signifie généralement dans un système d'authentification. Vous n'avez pas supprimé cet utilisateur pour le plaisir. Vous avez supprimé le compte d'un salarié qui partait. Vous avez retiré un compte dont le mot de passe est apparu dans un dump de fuite. Vous avez révoqué un octroi OAuth après la fuite d'un jeton. Vous avez retiré les accès d'un administrateur le jour de son départ. Chacune de ces actions est une décision de sécurité, et aucune ne survit à une sauvegarde qui ne voit pas les suppressions.&lt;/p&gt;

&lt;p&gt;Alors vous restaurez. Peut-être après un vrai sinistre, peut-être juste vers un clone de staging. La sauvegarde fait exactement ce que vous lui avez demandé : elle remet chaque ligne qu'elle connaît. Le salarié parti est de nouveau un utilisateur. Le mot de passe compromis est de nouveau valide. L'octroi révoqué fonctionne de nouveau. L'administrateur écarté est de nouveau administrateur. Votre restauration n'a pas perdu de données. Elle a annulé vos décisions de sécurité, en silence, et vous a rendu un système qui a l'air sain et qui est discrètement compromis. &lt;strong&gt;Une sauvegarde qui oublie une suppression est pire que pas de sauvegarde du tout, parce que vous lui faites confiance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;La solution, c'est d'arrêter de traiter une suppression comme l'absence d'une ligne et de commencer à la traiter comme un événement. Les suppressions sont des données.&lt;/p&gt;

&lt;p&gt;Nous donnons donc aux suppressions leur propre table. Chaque suppression du système passe par un &lt;code&gt;ITombstoneWriter&lt;/code&gt; injecté qui enregistre un tombstone, une pierre tombale : la clé, et l'heure de la mort. Il n'existe aucun chemin de code qui supprime une ligne sans laisser de tombstone, parce que la suppression et le tombstone sont une seule opération. Une sauvegarde incrémentale devient alors deux parties capturées à un même repère : les upserts (les lignes à l'horodatage plus récent) et les tombstones (les suppressions depuis le dernier repère). La restauration rejoue les deux dans l'ordre chronologique, si bien qu'une suppression s'applique exactement comme une écriture, et qu'une clé supprimée puis recréée se résout à ce qui est arrivé en dernier. L'espace vide a enfin un enregistrement qui lui est attaché.&lt;/p&gt;

&lt;p&gt;Voilà toute l'astuce, et elle est petite. La raison pour laquelle elle compte ne l'est pas.&lt;/p&gt;

&lt;p&gt;Le schéma dépasse Table Storage et DynamoDB. Tout système qui modélise une suppression comme « la ligne n'est plus là » ne peut ni sauvegarder, ni répliquer, ni synchroniser les suppressions, parce qu'il n'y a rien à transporter. On le retrouve dans les replays d'événements naïfs qui ne rejouent que les créations et les mises à jour. On le retrouve dans les caches qui expirent mais ne s'invalident jamais. On le retrouve dans les réplicas de lecture qui dérivent parce que la suppression ne s'est jamais propagée. Et il est le plus dangereux exactement là où les suppressions sont votre moyen d'appliquer la sécurité, c'est-à-dire partout dans un système d'identité : révocation, offboarding, rotation, verrouillage. Si votre stratégie de durabilité ne suit que ce qui existe, elle préservera fidèlement ce que vous vous êtes donné du mal à faire disparaître.&lt;/p&gt;

&lt;p&gt;Auditez donc vos propres sauvegardes avec une seule question : si je supprime un utilisateur maintenant, que je prends une sauvegarde et que je la restaure, cet utilisateur a-t-il disparu ? Si la réponse honnête est « je n'en suis pas sûr », votre sauvegarde est une machine à remonter le temps pointée dans le mauvais sens. Elle ne vous protège pas de vos erreurs. Elle les ressuscite : le mot de passe remplacé, le salarié parti, le jeton révoqué.&lt;/p&gt;

&lt;p&gt;Les suppressions sont des données. Sauvegardez-les comme telles.&lt;/p&gt;

&lt;p&gt;Si vous préférez que vos sauvegardes respectent une suppression sans avoir à le prouver vous-même, c'est à ça que sert une plateforme. &lt;a href="https://authagonal.io" rel="noopener noreferrer"&gt;Authagonal&lt;/a&gt; pose un tombstone sur chaque suppression, si bien qu'une restauration ne ramène jamais un identifiant révoqué.&lt;/p&gt;

</description>
      <category>backup</category>
      <category>storage</category>
      <category>security</category>
    </item>
    <item>
      <title>The admission webhook that said yes and took us down anyway</title>
      <dc:creator>authagonal</dc:creator>
      <pubDate>Mon, 27 Jul 2026 00:52:16 +0000</pubDate>
      <link>https://dev.to/authagonal/the-admission-webhook-that-said-yes-and-took-us-down-anyway-3p3g</link>
      <guid>https://dev.to/authagonal/the-admission-webhook-that-said-yes-and-took-us-down-anyway-3p3g</guid>
      <description>&lt;p&gt;We run an auth service, so the question "is the container that just started actually the container we built?" is not academic. Our answer was signatures: sign every image in CI, verify before deploy, and then, as a second layer, put a webhook in the cluster so that even a hand-typed &lt;code&gt;kubectl&lt;/code&gt; could not run something unsigned. That went in on a Tuesday afternoon. By evening our auth Deployment on the dev cluster had accumulated 2,242 ReplicaSets, was minting a new one roughly every three seconds, reported &lt;code&gt;Available=False&lt;/code&gt;, and served nothing.&lt;/p&gt;

&lt;p&gt;The obvious hypothesis is that the new webhook was rejecting our images. It was not. Its logs, for every request, said &lt;code&gt;allowed: true&lt;/code&gt;. It admitted everything we sent it, all evening, while the Deployment it was admitting fell apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layer we were adding
&lt;/h2&gt;

&lt;p&gt;The pipeline already signed and verified. Every image gets a keyless signature at build time, tied to the GitHub Actions OIDC identity, and the deploy job runs a verification against that identity before anything reaches the cluster. That gate is fail-closed and it is the real control.&lt;/p&gt;

&lt;p&gt;The cluster-side layer is defence in depth: sigstore's policy-controller, running as an admission webhook, holding two policies. One says images matching our own registry path must carry a keyless signature from our workflow identity. The other is a catch-all that lets everything else pass, because "no policy matched" means deny, and without the catch-all the cluster loses its Vault agents, its CSI drivers, and every sidecar it did not build. Install, label the namespace, done. The label is the switch: no label, no enforcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first outage, which was boring
&lt;/h2&gt;

&lt;p&gt;Turning it on with &lt;code&gt;failurePolicy: Fail&lt;/code&gt; and the chart's default ten second timeout took dev down almost immediately, in the way everyone expects an admission webhook to take you down. A cold controller has to reach Fulcio and Rekor to verify a signature it has not seen before. Cold, that work does not finish in ten seconds. Fail-closed plus a blown deadline means pod creation is refused, which means the rollout cannot place pods, which means the service has no replicas.&lt;/p&gt;

&lt;p&gt;That failure mode is well documented and the fix is the documented one: raise the webhook timeout to thirty seconds and set &lt;code&gt;failurePolicy: Ignore&lt;/code&gt;. Ignore sounds like giving up, and in a single-layer design it would be. In ours the pipeline gate is the fail-closed primary, and the cluster layer exists to catch what never went through the pipeline at all. A webhook that can never take the cluster down is worth more to us than a webhook that catches the last one percent, because the last one percent has already been caught upstream.&lt;/p&gt;

&lt;p&gt;We shipped that at 17:40. It fixed the first outage completely. It also created the second one, and this is the part worth reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mutating, not just validating
&lt;/h2&gt;

&lt;p&gt;Everyone thinks of an admission webhook as a bouncer: it inspects the object and returns yes or no. Policy-controller is not only that. It is a &lt;em&gt;mutating&lt;/em&gt; webhook, and what it mutates is the thing it just verified. When it admits a pod spec that references an image by tag, it rewrites that reference to include the digest it resolved and checked. &lt;code&gt;myregistry.io/authagonal-auth:abc123&lt;/code&gt; goes in. &lt;code&gt;myregistry.io/authagonal-auth:abc123@sha256:...&lt;/code&gt; comes out.&lt;/p&gt;

&lt;p&gt;That is a genuinely good idea. A tag is a mutable pointer, so verifying a tag and then letting the kubelet resolve it again later leaves a window where the two could differ. Pinning the digest at admission time closes it. The object that runs is the object that was verified.&lt;/p&gt;

&lt;p&gt;Now put that next to how a Deployment works. The Deployment controller hashes your pod template, and that hash is what identifies the ReplicaSet that owns the pods. It hashes the template &lt;em&gt;you&lt;/em&gt; declared. The webhook rewrites the template on the ReplicaSet it admits. So if your Deployment's template says &lt;code&gt;:abc123&lt;/code&gt;, its own child ReplicaSet says &lt;code&gt;:abc123@sha256:...&lt;/code&gt;, and the two no longer agree.&lt;/p&gt;

&lt;p&gt;The controller reconciles, hashes the template, looks for a ReplicaSet with that hash, and finds one whose template is different. There is exactly one thing that means in Kubernetes: a hash collision, two different templates landing on the same hash. The controller handles collisions the way it is supposed to. It increments &lt;code&gt;collisionCount&lt;/code&gt;, which perturbs the hash, and creates a new ReplicaSet. Three seconds later it reconciles again, and the new ReplicaSet has been mutated too. The loop does not converge, because the disagreement it is trying to resolve is being recreated by the webhook every time it tries.&lt;/p&gt;

&lt;p&gt;Two thousand two hundred and forty-two ReplicaSets is what an infinite loop looks like when you catch it in the evening rather than the morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why only one Deployment broke
&lt;/h2&gt;

&lt;p&gt;Four workloads run in that namespace. One spiralled. Three were completely fine, which is the detail that kept us looking in the wrong place, because a systemic misconfiguration should not be selective.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;failurePolicy: Ignore&lt;/code&gt; is why. When the webhook is slow or cold and a request times out, Ignore means the object is admitted &lt;em&gt;unmutated&lt;/em&gt;. Whether a given apply came back with a digest baked in or with the bare tag it went in with depended on whether the webhook answered in time. The three healthy Deployments had been applied while it was warm, so their templates already carried digests, and the webhook's rewrite was a no-op that matched what was already there. The one that spiralled had been applied while the webhook was cold, kept its bare tag in the template, and then had every child ReplicaSet mutated out from under it.&lt;/p&gt;

&lt;p&gt;So the trigger was a race, decided per apply, that any of the four could have lost on any deploy. Ignore did not cause the bug. It turned a deterministic bug into an intermittent one and hid it behind three healthy workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is a fixed point
&lt;/h2&gt;

&lt;p&gt;The instinct is to stop the webhook from mutating. Better instinct: give it nothing to mutate. If the pod template we submit is already exactly what the webhook would produce, the rewrite changes nothing, the hash stays stable, and the loop cannot start.&lt;/p&gt;

&lt;p&gt;So the deploy job now resolves the digest itself before applying. For each image it asks the registry what the tag currently points at, then writes the fully pinned reference into the overlay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az acr repository show &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$acr&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; &lt;span class="s2"&gt;"authagonal-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;img&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;tag&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; digest &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
kustomize edit &lt;span class="nb"&gt;set &lt;/span&gt;image &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ref&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ref&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;tag&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;digest&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What ships is &lt;code&gt;:tag@sha256:...&lt;/code&gt;, tag and digest together. The tag stays for human readability, the digest is what actually resolves. The webhook verifies it, finds nothing to rewrite, and returns the object unchanged. &lt;code&gt;collisionCount&lt;/code&gt; has been flat ever since.&lt;/p&gt;

&lt;p&gt;There is a small trap in the tail of this. Because CI is what writes the digest, applying the overlay by hand from a laptop sends the base manifests' placeholder tag instead, and the policy now correctly refuses it with "must be an image digest." The cluster is telling you the truth: that thing you just typed was never verified.&lt;/p&gt;

&lt;h2&gt;
  
  
  A mutating webhook is a second writer, not a checkpoint
&lt;/h2&gt;

&lt;p&gt;A mutating admission webhook is not a checkpoint. It is a writer inside a control loop that is already comparing what you asked for against what exists. Two writers with different opinions about the same field is not a policy question, it is a distributed systems question, and the one thing that makes it safe is idempotence: the state you declare has to be a fixed point of the mutation, so that applying the mutation to it produces it again.&lt;/p&gt;

&lt;p&gt;That reframing generalises past sigstore. Anything that rewrites your specs at admission time, whether it injects sidecars, adds default resource limits, or normalises image references, is in the same position, and the same question applies. If you ran your own manifest through this thing, would you get your own manifest back? If not, something is going to keep noticing the difference. It will be patient, and it will be much faster than you.&lt;/p&gt;

&lt;p&gt;And the smaller lesson, the one we actually had to unlearn on the night: &lt;code&gt;allowed: true&lt;/code&gt; does not mean the webhook is not the problem. We spent an hour treating those logs as an alibi. The webhook was telling the truth the whole time. We were asking it the wrong question, because we had only ever thought of it as something that says no.&lt;/p&gt;

&lt;p&gt;If you would rather your identity provider came with its supply chain already argued out, &lt;a href="https://authagonal.io" rel="noopener noreferrer"&gt;Authagonal&lt;/a&gt; is a hosted auth service whose images are signed in CI, verified before deploy, and pinned by digest at the point of admission, which is a sentence we can write because we already lost an evening earning it.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>security</category>
      <category>supplychain</category>
    </item>
    <item>
      <title>We turned off a dangerous default without migrating a single row</title>
      <dc:creator>authagonal</dc:creator>
      <pubDate>Fri, 24 Jul 2026 09:48:15 +0000</pubDate>
      <link>https://dev.to/authagonal/we-turned-off-a-dangerous-default-without-migrating-a-single-row-4fam</link>
      <guid>https://dev.to/authagonal/we-turned-off-a-dangerous-default-without-migrating-a-single-row-4fam</guid>
      <description>&lt;p&gt;Just-in-time provisioning is the feature that makes enterprise SSO feel like magic. A new employee signs in through their company's identity provider, no account exists for them in your app yet, and one is created on the spot from the assertion. Nobody files a ticket, nobody gets invited, the person just works.&lt;/p&gt;

&lt;p&gt;It is also, read from the other direction, a feature that lets whoever controls that identity provider create accounts in your customer's tenant by asserting that a person exists. That is fine when the connection is scoped tightly to one company's directory and every human in it should have access. It is less fine when the connection is a shared directory, or a contractor tenant, or one of those sprawling federations where the set of people the identity provider is willing to vouch for is much larger than the set of people your customer meant to let in.&lt;/p&gt;

&lt;p&gt;Ours defaulted to on. Not because anybody decided it should, which is the part worth dwelling on. It defaulted to on because when the field was added, the boolean that expressed it was called &lt;code&gt;DisableJitProvisioning&lt;/code&gt;, and an unset boolean is false, and false meant "do not disable." The safest reading of a default nobody chose is that it is an accident, and this one had settled in as behaviour.&lt;/p&gt;

&lt;p&gt;To be precise about the exposure, because it was never as bad as "anyone can create anyone": two gates already ran before provisioning. A connection can carry a list of allowed email domains, and an assertion outside them is rejected. A connection can require an invitation attribute, and an uninvited user is rejected. The real default-on risk was a connection with neither of those configured, which is exactly the shape of a connection somebody set up quickly to get SSO working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flipping the default is one word. Flipping it safely is not.
&lt;/h2&gt;

&lt;p&gt;The change everyone imagines is renaming the field to &lt;code&gt;JitProvisioningEnabled&lt;/code&gt; and letting it default to false. New connections are secure by default, done.&lt;/p&gt;

&lt;p&gt;Except that field is persisted, and there are connections in storage that were written before it existed. Their rows do not have the column at all. What happens to them depends entirely on which direction the boolean points, because a missing column deserializes to false either way. Under the old negative name, missing means "not disabled" and provisioning continues. Under a new positive name, missing means "not enabled" and provisioning stops.&lt;/p&gt;

&lt;p&gt;So a straight rename silently turns off just-in-time provisioning for every connection a customer configured back when it was on. They chose nothing, they were not told, and the first they hear about it is an employee who cannot sign in, at whatever hour that happens. That is not a security improvement, it is an outage delivered by deploy.&lt;/p&gt;

&lt;p&gt;The obvious answer is a backfill: walk every stored connection, write the column explicitly, then flip the default. It works, and it is a migration you have to write, test, run against every tenant's storage, and be sure completed everywhere before the code that depends on it ships. For a boolean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The double negative
&lt;/h2&gt;

&lt;p&gt;We did not write the migration. The stored column keeps its old negative meaning forever, and the model gains a positive property in front of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;JitProvisioningEnabled&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;DisableJitProvisioning&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;get&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;JitProvisioningEnabled&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;JitProvisioningEnabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The positive property is the real one, with real storage behind it, and it defaults to false, which is the new safe default. The negative name is now a computed alias that inverts in both directions.&lt;/p&gt;

&lt;p&gt;Follow an old row through. The column is missing, so it reads as false, so the setter for &lt;code&gt;DisableJitProvisioning&lt;/code&gt; runs with false, so &lt;code&gt;JitProvisioningEnabled&lt;/code&gt; becomes true. The connection keeps provisioning, exactly as its owner configured it, and nothing was migrated. Follow a new connection through. Nobody sets either property, &lt;code&gt;JitProvisioningEnabled&lt;/code&gt; stays at its default of false, and the connection rejects unknown users until somebody opts in.&lt;/p&gt;

&lt;p&gt;Both behaviours come out of the same code with no branch, no version flag, and no data touched. The persisted bit never changed meaning. Only the field it lands in did, and the inversion happens in a property setter that runs on every load.&lt;/p&gt;

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

&lt;p&gt;This is not free, and the bill arrives at the API boundary. Both properties are public, so both get serialized, and a client that reads a connection, changes something, and writes it back is now sending two properties that describe the same thing. Deserialization applies them in the order they appear in the payload, so the last one wins. Set the positive property to true while leaving a stale negative one in the object you fetched, and your change is silently undone by a field you did not think you were sending.&lt;/p&gt;

&lt;p&gt;We found that the way you find these things, in a test that flipped the flag on and then asserted it was on. The rule that came out of it is to set both forms explicitly on any read-modify-write, which our own end-to-end test now does with a comment explaining why. If you adopt this trick, budget for that. A two-way alias buys you a free migration and charges you an ambiguity on the wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug the flip uncovered
&lt;/h2&gt;

&lt;p&gt;Here is the part that generalises past booleans. While making the change, we found that the admin endpoint for creating an OIDC connection had never set this flag at all. Not incorrectly, not to the wrong value. It simply never assigned it, and the request object had no field for it to assign.&lt;/p&gt;

&lt;p&gt;That was invisible for as long as the default was the value everyone wanted. Every connection came out provisioning, which is what the code that forgot to wire it would have produced anyway, so there was nothing to notice and no test that could have failed. The moment the default flipped, that same gap became "every newly created OIDC connection has provisioning off and no way to turn it on," which is not a subtle bug at all.&lt;/p&gt;

&lt;p&gt;A default is the value of every code path that forgot to set the field. While the default is convenient, those paths are indistinguishable from the ones that set it deliberately. Changing a default does not just change new behaviour, it develops the photograph: everything that was silently relying on the default becomes visible at once, and some of it is broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checkbox that did not move
&lt;/h2&gt;

&lt;p&gt;The last place a default hides is the user interface. Our portal had a checkbox that read "Disable JIT provisioning," unchecked by default. It now reads "Enable JIT provisioning," and it is still unchecked by default. Same widget in the same position with the same initial state, and the opposite meaning.&lt;/p&gt;

&lt;p&gt;That is a genuinely dangerous kind of change, so the list view gained a badge. Any connection not opted in is now labelled, so the state is visible without opening anything, rather than being inferred from an unchecked box that used to mean the other thing.&lt;/p&gt;

&lt;p&gt;And when a connection with provisioning off receives an assertion for somebody unknown, the user is not dumped on a stack trace. They go back to the application they came from with an error that says the account was not found and to contact their administrator, which is the true and actionable version of what just happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defaults are API surface, inherited by four populations
&lt;/h2&gt;

&lt;p&gt;Defaults are API surface. They are inherited by stored rows that predate the field, by configuration files that omit it, by code paths that never set it, and by user interface controls whose unchecked state encodes them. Before you move one, enumerate those four populations and decide, for each, whether it should follow the new default or keep the old behaviour. Usually the answer is different for each, and that is the design work.&lt;/p&gt;

&lt;p&gt;And if you find you are about to write a data migration to move a boolean, look first at whether the meaning can stay put while the name and the default move in front of it. Storage is the expensive place to change your mind. A property setter is the cheap one.&lt;/p&gt;

&lt;p&gt;If you would rather your identity provider shipped with the careful defaults already chosen, &lt;a href="https://authagonal.io/features" rel="noopener noreferrer"&gt;Authagonal&lt;/a&gt; makes every SSO connection opt in to provisioning, and tells you plainly which ones have.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Our login hung for exactly 10 seconds. Our own security headers did it.</title>
      <dc:creator>authagonal</dc:creator>
      <pubDate>Thu, 23 Jul 2026 00:00:37 +0000</pubDate>
      <link>https://dev.to/authagonal/our-login-hung-for-exactly-10-seconds-our-own-security-headers-did-it-45aa</link>
      <guid>https://dev.to/authagonal/our-login-hung-for-exactly-10-seconds-our-own-security-headers-did-it-45aa</guid>
      <description>&lt;p&gt;For about a week, returning users who opened our portal watched the word "Loading…" sit on the screen for ten seconds before the login page appeared. Not sometimes. Not roughly. Ten seconds, every time, and then everything worked perfectly. First-time visitors never saw it. Only people who had signed in before.&lt;/p&gt;

&lt;p&gt;A bug that takes a random amount of time is a performance problem. A bug that takes &lt;em&gt;exactly&lt;/em&gt; ten seconds is a confession. Nothing in a healthy web request rounds itself off to a clean power of ten. That number is not the sum of some real work; it is the ceiling of a timeout, and a timeout means something, somewhere, is patiently waiting for a thing that is never going to arrive.&lt;/p&gt;

&lt;p&gt;This is the story of what it was waiting for, and why the thing it waited for was blocked by a security header we were proud of.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the portal does on mount
&lt;/h2&gt;

&lt;p&gt;Our portal is a single-page app. When it loads, before it shows you anything, it tries to answer one question: are you already signed in? The polite way to do that with OIDC is a &lt;em&gt;silent&lt;/em&gt; check. The app asks the identity provider "if this browser already has a session, hand me a fresh token without bothering the user." Our client library, oidc-client-ts, exposes this as &lt;code&gt;signinSilent()&lt;/code&gt;, and we called it on mount from a &lt;code&gt;renewSession()&lt;/code&gt; helper.&lt;/p&gt;

&lt;p&gt;There are two ways that silent check can run. If the app is holding a refresh token, the library does a quiet back-channel exchange, no UI involved, and you are in. If it is &lt;em&gt;not&lt;/em&gt; holding a refresh token, the library falls back to the older mechanism: it opens a hidden iframe pointed at the identity provider's authorize endpoint with &lt;code&gt;prompt=none&lt;/code&gt;, and waits for that iframe to post a result back. The whole point of the iframe is that it is invisible. You are never supposed to see it, and on a healthy setup you never do, because it resolves in milliseconds.&lt;/p&gt;

&lt;p&gt;Ours never resolved at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wall we built ourselves
&lt;/h2&gt;

&lt;p&gt;The iframe loads the auth host. And the auth host, like every host we run that has any business being taken seriously, ships two headers whose entire job is to say "you may not put me in a frame":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;X-Frame-Options: DENY&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Content-Security-Policy: frame-ancestors 'none'&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are clickjacking defenses, and they are correct. An attacker who can iframe your login page can float it under a decoy, trick a user into typing real credentials into what looks like something harmless, and harvest them. &lt;code&gt;frame-ancestors 'none'&lt;/code&gt; is the modern instruction that no origin, not even our own, may embed this page. We turned it on deliberately. It is exactly the kind of thing a security review looks for and rewards.&lt;/p&gt;

&lt;p&gt;So when oidc-client-ts opened its hidden iframe against that host, the browser did precisely what we had told it to: it refused to render the page in a frame. And here is the cruel part. A refused frame does not throw. There is no error event for the library to catch, no rejected promise, no console line. The iframe just sits there, empty, indefinitely. From the library's point of view nothing has happened yet, so it does the only thing it can. It waits for its timeout. That timeout, the default &lt;code&gt;silentRequestTimeout&lt;/code&gt;, is ten seconds.&lt;/p&gt;

&lt;p&gt;Ten seconds of a hidden iframe staring at a blank wall, then the library gives up, the promise finally rejects, the app shrugs and redirects you to the real login page, and everything works. The hang was never a failure. It was a success that took the scenic route through a doomed iframe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two correct things, one bad seam
&lt;/h2&gt;

&lt;p&gt;What made this genuinely hard to see is that nothing was broken. The security headers were right. The silent-renew fallback was right, a legitimate and widely used OIDC pattern. Every component behaved exactly as designed and exactly as any reviewer would want. The ten-second hang did not live inside any one of them. It lived in the space &lt;em&gt;between&lt;/em&gt; them, in the assumption each made about the other. The SSO library assumed it could frame the identity provider. The identity provider assumed nobody should ever be allowed to frame it. Both assumptions were defensible. They were simply incompatible, and no single file contained the contradiction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it reached for the iframe at all
&lt;/h2&gt;

&lt;p&gt;That still left a question. The fast path, the refresh-token exchange, would have skipped the iframe entirely. Why were returning users landing on the slow path? Because they had no refresh token to hold. And they had no refresh token because our own portal OAuth clients had been provisioned without &lt;code&gt;AllowOfflineAccess&lt;/code&gt;, the flag that authorizes a client to be issued one. No offline access, no refresh token, no fast path, and every returning user was shunted into the iframe that could never load.&lt;/p&gt;

&lt;p&gt;That was the real defect, and it was a data problem spread across every tenant, not a one-line code change we could ship once. So the repair is a reconcile service that re-applies &lt;code&gt;AllowOfflineAccess&lt;/code&gt; to the portal clients of every tenant at startup, correcting the whole fleet on the next deploy without anyone touching a tenant by hand. Refresh tokens started flowing again, and the fast path came back to life on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the lesson
&lt;/h2&gt;

&lt;p&gt;The reconcile service fixed the cause. But a login should not stall for ten seconds even when it does end up on the slow path, so we hardened the seam too. &lt;code&gt;renewSession()&lt;/code&gt; now inspects the stored user first: if there is no refresh token in hand, it short-circuits and returns nothing immediately, skips the iframe it already knows is doomed, and sends the user straight to interactive login. The refresh-token fast path is untouched. And as a backstop for any background renewal that still opens an iframe, we cut the timeout from ten seconds to five, so the worst case is half as bad.&lt;/p&gt;

&lt;p&gt;The lesson we actually kept is about a category of bug, not this one instance. A hang is a bug, even though it emits no error, no exception, no red line in a log. The only evidence it leaves is elapsed time. And when that time is a clean round number, do not go hunting for slow work to optimize. Go hunting for a timeout, and then find the thing on the other end of it that is silently, permanently, never going to answer. Ours was an iframe, knocking politely on a door we had bolted shut on purpose.&lt;/p&gt;

&lt;p&gt;If you would rather your login flow already knew that a locked-down auth host and a silent-renew iframe do not mix, that is a seam we have already run into so that you never have to. &lt;a href="https://authagonal.io" rel="noopener noreferrer"&gt;Authagonal&lt;/a&gt; ships the SSO plumbing and the security headers as one system that was tested together, not as two correct halves you get to discover are incompatible at ten seconds a page load.&lt;/p&gt;

</description>
      <category>oidc</category>
      <category>sso</category>
      <category>csp</category>
      <category>frontend</category>
    </item>
    <item>
      <title>HMAC ate my autocomplete: type-ahead search over encrypted emails</title>
      <dc:creator>authagonal</dc:creator>
      <pubDate>Wed, 22 Jul 2026 01:42:36 +0000</pubDate>
      <link>https://dev.to/authagonal/hmac-ate-my-autocomplete-type-ahead-search-over-encrypted-emails-4ae0</link>
      <guid>https://dev.to/authagonal/hmac-ate-my-autocomplete-type-ahead-search-over-encrypted-emails-4ae0</guid>
      <description>&lt;p&gt;We encrypt user PII at rest with per-tenant keys, so that a leaked database dump exposes nothing useful. The email column is ciphertext. Phone numbers, names, custom attributes — ciphertext. We're proud of this. It's a selling point.&lt;/p&gt;

&lt;p&gt;And the day it went live, the admin search box quietly stopped autocompleting.&lt;/p&gt;

&lt;p&gt;No error. No log line. Type &lt;code&gt;ali&lt;/code&gt; into user search and the tenant admin who used to see &lt;code&gt;alistair@acme.com&lt;/code&gt; pop up after three keystrokes now saw... nothing, unless they typed the &lt;em&gt;entire&lt;/em&gt; email address, exactly. Search hadn't broken — it had silently degraded from "starts with" to "equals," and nothing in the system considered that worth mentioning.&lt;/p&gt;

&lt;p&gt;This is the story of getting type-ahead back over data we refuse to store in plaintext, and the three traps we hit doing it. The cryptography turned out to be the easy part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why encryption eats autocomplete
&lt;/h2&gt;

&lt;p&gt;With plaintext, prefix search is what databases are &lt;em&gt;for&lt;/em&gt;. Keep an index ordered by email and &lt;code&gt;starts with "ali"&lt;/code&gt; is a range scan: everything &lt;code&gt;&amp;gt;= "ali"&lt;/code&gt; and &lt;code&gt;&amp;lt; "alj"&lt;/code&gt;. Cheap, obvious, done.&lt;/p&gt;

&lt;p&gt;Encrypt the column and the ordered index is gone. The standard replacement is a &lt;strong&gt;blind index&lt;/strong&gt;: alongside the ciphertext, store a keyed HMAC of the value, and look users up by recomputing the HMAC of the search term. &lt;code&gt;HMAC(key, "alistair@acme.com")&lt;/code&gt; is deterministic, so &lt;em&gt;exact-match&lt;/em&gt; lookup works perfectly — and the index leaks nothing readable, because without the per-tenant key you can't compute a digest to compare against.&lt;/p&gt;

&lt;p&gt;But notice what HMAC is &lt;em&gt;for&lt;/em&gt;. Its entire design goal is that similar inputs produce unrelated outputs — flip one bit, get a completely different digest. &lt;code&gt;HMAC("ali")&lt;/code&gt; and &lt;code&gt;HMAC("alistair")&lt;/code&gt; have nothing to do with each other. The property that makes the blind index safe to leak is precisely the property that makes it unable to answer "starts with." Ordering &lt;em&gt;is&lt;/em&gt; leakage. A blind index doesn't accidentally break prefix search; it breaks it on principle.&lt;/p&gt;

&lt;p&gt;So the search box degraded to exact-match, silently, because exact-match was the only question the index could still answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design: index every prefix as its own value
&lt;/h2&gt;

&lt;p&gt;If the index can only answer "equals," then turn "starts with" into "equals."&lt;/p&gt;

&lt;p&gt;Every prefix of the normalized email local part — the bit before the &lt;code&gt;@&lt;/code&gt; — gets its own blind-index row. For &lt;code&gt;alistair@acme.com&lt;/code&gt;, that's rows for &lt;code&gt;al&lt;/code&gt;, &lt;code&gt;ali&lt;/code&gt;, &lt;code&gt;alis&lt;/code&gt;, &lt;code&gt;alist&lt;/code&gt;, and so on: PartitionKey = &lt;code&gt;HMAC(prefix)&lt;/code&gt;, RowKey = the user id. Now "starts with &lt;code&gt;ali&lt;/code&gt;" is an exact-match lookup on &lt;code&gt;HMAC("ali")&lt;/code&gt; — one point query, no ordering required. Our name search had already worked this way for the same reason; email just joined it.&lt;/p&gt;

&lt;p&gt;Two constants keep it sane. Prefixes start at &lt;strong&gt;2 characters&lt;/strong&gt; (one-character lookups were never useful and double the rows) and cap at &lt;strong&gt;16&lt;/strong&gt; (bounds the fan-out per email; a longer query just matches on its first 16 characters, and the handful of candidates get filtered after decryption). So each email costs at most 15 index rows — written on create, moved on email change, removed on delete.&lt;/p&gt;

&lt;p&gt;That's the trade in one sentence: &lt;strong&gt;you buy back the ordering you refused to leak, and you pay for it in write fan-out.&lt;/strong&gt; Storage and writes are cheap; leaked structure is not. It's a good trade.&lt;/p&gt;

&lt;p&gt;One subtlety in the move-on-change path earned its own comment in the code: the prefix rows are keyed on the &lt;em&gt;local part&lt;/em&gt;, so the rewrite has to trigger when the local part changes — independently of the domain. A same-domain rename, &lt;code&gt;alistair@acme.com&lt;/code&gt; → &lt;code&gt;wendy@acme.com&lt;/code&gt;, looks like "domain unchanged, skip the index work" to a guard written with the domain index in mind, and would leave the old prefix rows pointing at the renamed user forever.&lt;/p&gt;

&lt;p&gt;And to be honest about what we built: this index deliberately leaks &lt;em&gt;prefix-equality&lt;/em&gt; — an attacker with the table can see that two users share a 3-character email prefix, though not what it is. Searchable encryption never eliminates leakage; it lets you choose it, consciously, per query shape. Equality and prefix-equality are the leakage we chose. That framing — pick your leakage, then engineer everything else around it — is the whole discipline.&lt;/p&gt;

&lt;p&gt;The design worked. Then the systems problems started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap one: the key that couldn't be created
&lt;/h2&gt;

&lt;p&gt;Blind indexes need an HMAC key per tenant, provisioned in Vault's transit engine like our encryption keys. Creating one returned a 500: &lt;em&gt;invalid key size for HMAC key&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Vault requires an explicit &lt;code&gt;key_size&lt;/code&gt; for &lt;code&gt;hmac&lt;/code&gt;-type keys — 32 to 512 bytes — where the fixed-size types (&lt;code&gt;aes256-gcm96&lt;/code&gt;, &lt;code&gt;ecdsa-p256&lt;/code&gt;) forbid it. Our key-creation call sent only &lt;code&gt;{"type":"hmac"}&lt;/code&gt;. One missing field.&lt;/p&gt;

&lt;p&gt;Here's why this was a trap and not a bug report: every &lt;em&gt;tokenize&lt;/em&gt; operation threw — and the login path tokenizes, because finding a user by email at sign-in goes through the same blind index as admin search. Enabling encryption didn't break search. &lt;strong&gt;It broke login.&lt;/strong&gt; The feature whose sales pitch is "your users are safer" took sign-in down on first enable in dev. The fix is &lt;code&gt;key_size=32&lt;/code&gt; (HMAC-SHA256) for hmac keys, omitted for the fixed-size types — and a standing rule: smoke-test the tokenize path against a &lt;em&gt;real&lt;/em&gt; Vault before flipping encryption on anywhere. Mocks don't validate key-creation payloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap two: the update that could strand a user
&lt;/h2&gt;

&lt;p&gt;Changing an email means index maintenance: remove the old rows, write the new ones. Our first implementation did it in that order — delete, then write. Natural, tidy, wrong.&lt;/p&gt;

&lt;p&gt;Every new-row write now involves Vault (computing the HMAC PartitionKeys). Delete the old rows first, and a Vault hiccup during the write leaves the user with &lt;em&gt;neither&lt;/em&gt; the old lookup rows &lt;em&gt;nor&lt;/em&gt; the new ones. They exist, encrypted, in the table — and nothing can find them. Including login. That's not a degraded search; that's a locked-out user, until some future reindex sweeps by.&lt;/p&gt;

&lt;p&gt;The fix is ordering, not error handling: &lt;strong&gt;write before delete&lt;/strong&gt;, everywhere the index is maintained. A crash between the two steps now leaves an extra stale row — harmless, lazily cleaned — instead of a missing one. The failure mode moved from "user unreachable" to "one redundant row," for free. When a write path involves a remote dependency, pick the step order whose half-finished state you can live with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap three: the partition that ate an import
&lt;/h2&gt;

&lt;p&gt;We also keep a domain blind index — &lt;code&gt;HMAC(domain)&lt;/code&gt; → members — so "everyone at acme.com" is one lookup. Deterministic hashing has a consequence nobody prices in until an import: every user of one domain lands in &lt;em&gt;one partition&lt;/em&gt;. An Azure Table partition takes roughly 2,000 operations a second. A 50k-user single-domain import from Auth0 funnelled every domain-index write into exactly that bottleneck.&lt;/p&gt;

&lt;p&gt;The fix is bucketing: members spread across 16 partitions by a hash of the user id, and domain reads fan out over the buckets — bounded, and rare enough not to matter. Two details carried the lesson. The bucket hash is a hand-rolled FNV-1a, because .NET's &lt;code&gt;string.GetHashCode&lt;/code&gt; is deliberately unstable across processes — bucket by it and tomorrow's process computes a different bucket for the same user and can't find the row it's supposed to delete. And the read path still sweeps the legacy unbucketed partitions, so existing rows stayed findable with &lt;strong&gt;no forced backfill&lt;/strong&gt; — new writes distribute immediately, old rows migrate whenever the user is next touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;Nothing in this story is novel cryptography. HMAC is decades old; "hash the value, index the hash" fits in a sentence. Everything that actually cost us was systems work: which query shapes the product genuinely needs (equality, prefix, domain — each got its own index, because a blind index answers exactly one question); how keys get provisioned and what happens on the login path when they don't; which order index writes happen in when a remote KMS sits in the middle; and where deterministic hashing concentrates load that plaintext never did.&lt;/p&gt;

&lt;p&gt;Searchable encryption is sold as a crypto feature. Build it and you'll discover it's a distributed-systems feature wearing a crypto costume. The search box autocompletes again — &lt;code&gt;ali&lt;/code&gt; finds Alistair after three keystrokes — and a stolen dump of the same table shows HMAC digests fanned across sixteen buckets, which is to say: nothing. Both at once was always the point.&lt;/p&gt;

&lt;p&gt;Encrypting every user's PII at rest while keeping the admin search box instant is exactly the kind of thing you get to not build when you run auth on &lt;a href="https://authagonal.io/security" rel="noopener noreferrer"&gt;Authagonal&lt;/a&gt;. We already paid for all three traps.&lt;/p&gt;

</description>
      <category>encryption</category>
      <category>blindindex</category>
      <category>search</category>
      <category>vault</category>
    </item>
    <item>
      <title>OIDC oder SAML: welches Sie wirklich brauchen</title>
      <dc:creator>authagonal</dc:creator>
      <pubDate>Mon, 20 Jul 2026 23:30:28 +0000</pubDate>
      <link>https://dev.to/authagonal/oidc-oder-saml-welches-sie-wirklich-brauchen-27ga</link>
      <guid>https://dev.to/authagonal/oidc-oder-saml-welches-sie-wirklich-brauchen-27ga</guid>
      <description>&lt;p&gt;Jedes Team, das B2B-Software baut, steht an derselben Weggabelung, sobald ein ernstzunehmender Kunde zum ersten Mal sagt: „Wir brauchen SSO." Zwei Akronyme, OIDC und SAML, beide beanspruchen, die Antwort zu sein, und ein Internet voller Vergleichstabellen, die Ihnen erzählen, SAML sei „Enterprise" und OIDC sei „modern" und Sie genauso ratlos zurücklassen wie zuvor. Hier ist die Version, die Ihnen wirklich hilft, etwas auszuliefern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was sie sind
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SAML&lt;/strong&gt; stammt aus dem Jahr 2005 und ist XML. Ein Identity Provider signiert eine Assertion („das ist &lt;a href="mailto:alice@bigco.com"&gt;alice@bigco.com&lt;/a&gt;, hier sind ihre Gruppen") und schickt sie an Ihre App, die die Signatur prüft und sie anmeldet. Es wurde für den Browser und für Workforce-Identity gebaut, in einer Zeit, in der „das Unternehmen" ein On-Premises-Active Directory und einen SOAP-Stack bedeutete. Es ist umständlich, es ist alt, und es ist in großen Organisationen absolut überall – und das ist die eine Tatsache daran, die für Sie zählt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OIDC&lt;/strong&gt; stammt aus dem Jahr 2014 und ist JSON und JWTs, aufgesetzt auf OAuth 2.0. Ein Identity Provider stellt ein ID-Token aus, das Ihre App validiert. Es wurde für das moderne Web gebaut: SPAs, mobile Apps, APIs, Social Login. Es ist sauberer, besser spezifiziert für die Dinge, die Sie heute tatsächlich bauen, und das Protokoll, das die meisten Identity-Projekte auf der grünen Wiese inzwischen sprechen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wann welches gewinnt
&lt;/h2&gt;

&lt;p&gt;Die ehrliche Antwort auf „welches soll ich bauen" lautet: Sie haben fast nie die Wahl. Sie bauen das, was die IT-Abteilung Ihres Kunden ausgewählt hat – und sie hat es ausgewählt, lange bevor sie je von Ihnen gehört hat.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ein Kunde auf Okta, Entra ID oder Google Workspace kann meist beides, und OIDC ist der angenehmere Weg.&lt;/li&gt;
&lt;li&gt;Ein Kunde mit einem älteren ADFS, einem veralteten On-Premises-IdP oder einer 2016 verfassten Beschaffungs-Checkliste reicht Ihnen einen Brocken SAML-Metadaten und eine Kalendereinladung – und damit ist die Diskussion beendet.&lt;/li&gt;
&lt;li&gt;Ihre eigenen First-Party-Apps, Ihr Dashboard und Ihr Mobile-Client, wollen OIDC, Punkt. Sie würden niemals zu SAML greifen, um einen Nutzer in Ihrer eigenen React-App anzumelden.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Das Feld teilt sich also sauber auf: OIDC für das Moderne und das First-Party, SAML für „weil das Unternehmen es so verlangt". Verkaufen Sie an genug Unternehmen, und Sie werden nach beidem gefragt. Nicht irgendwann. Immer wieder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Die Fallstricke – und hier wird Selbstbauen teuer
&lt;/h2&gt;

&lt;p&gt;SAMLs Problem ist, dass es ein Protokoll mit signiertem XML ist, und signiertes XML gehört zum Verlässlich-Gefährlichsten in der angewandten Kryptografie. Die Wege, die SAML-Signaturprüfung falsch zu machen, sind zahlreich und berüchtigt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Signature Wrapping (XSW):&lt;/strong&gt; Ein Angreifer verschiebt das signierte Element und schiebt eine unsignierte, gefälschte Assertion genau dorthin, wo Ihr Parser tatsächlich liest. Wenn Sie die Signatur prüfen und die Assertion in zwei getrennten Schritten lesen, sind Sie wahrscheinlich angreifbar – und fast jede erste Implementierung macht genau das.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kanonisierung und Comment Injection:&lt;/strong&gt; die Bug-Klasse von 2018, bei der &lt;code&gt;user@company.com&amp;lt;!----&amp;gt;.evil.com&lt;/code&gt; für die Signaturprüfung auf eine Weise kanonisiert wird und für die Zeichenkette, die Ihr Code liest, auf eine andere – sodass Sie fröhlich die falsche Person authentifizieren. Echte CVEs, mehrere große Bibliotheken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Die leiseren:&lt;/strong&gt; die Response signieren, aber nicht die Assertion; unsignierte Assertions akzeptieren; dem vom IdP gelieferten Issuer vertrauen, ohne ihn zu pinnen; das Gültigkeitsfenster der Assertion falsch setzen. Jeder davon ist ein eigener Fallstrick, und jeder ist schon von Leuten, die wussten, was sie tun, in Produktion ausgeliefert worden.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OIDC ist deutlich vernünftiger, aber nicht frei von scharfen Kanten. Sie müssen immer noch die richtigen Claims validieren (&lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;aud&lt;/code&gt;, &lt;code&gt;exp&lt;/code&gt;, die &lt;code&gt;nonce&lt;/code&gt;), PKCE verwenden, den längst toten Implicit Flow ablehnen und JWKS rotieren und cachen, ohne ein Token zurückzuweisen, das mit einem Schlüssel signiert wurde, den Sie noch nicht abgerufen haben. Der Unterschied ist, dass OIDCs Fallen dokumentiert, JSON-förmig und in den meisten Bibliotheken standardmäßig korrekt behandelt sind. SAMLs Fallen sind XML-förmig und haben Sicherheitsteams verschlungen, die weit besser ausgestattet waren als Ihres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Die wahre Antwort
&lt;/h2&gt;

&lt;p&gt;„OIDC oder SAML" ist die falsche Frage, denn die richtige Antwort für ein B2B-Produkt lautet „ja". Ihre modernen Kunden und Ihre eigenen Apps wollen OIDC. Ihre Enterprise-Kunden werden SAML vorschreiben, nach einem Zeitplan, den Sie nicht kontrollieren. Bauen Sie für eines, und der dritte Verkaufstermin macht es kaputt.&lt;/p&gt;

&lt;p&gt;Was Sie wirklich brauchen, ist eine Möglichkeit, das jeweils mitgebrachte Protokoll jedes Kunden anzunehmen, ohne zwei Stacks, zwei Sätze Metadaten-Klempnerei und zwei voneinander unabhängige Gelegenheiten aufzustellen, die Signaturprüfung falsch zu machen. Die Implementierung ist der Preis. Die Wahl war nie der schwere Teil.&lt;/p&gt;

&lt;p&gt;Genau diesen Teil nimmt &lt;a href="https://authagonal.io" rel="noopener noreferrer"&gt;Authagonal&lt;/a&gt; Ihnen ab. Jeder Tenant bekommt SAML 2.0 mit Metadaten-Import per Klick und OIDC-Föderation mit den Providern, die Ihre Kunden ohnehin betreiben, auf demselben Login, ohne Gebühr pro Verbindung für beides. Sie implementieren keine XML-Signaturprüfung, Sie hüten keinen JWKS-Cache, und Sie bauen nichts davon neu, wenn der nächste Kunde auftaucht und das andere Protokoll spricht. &lt;a href="https://authagonal.io/features" rel="noopener noreferrer"&gt;Sehen Sie, was enthalten ist.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>auth</category>
      <category>oidc</category>
      <category>saml</category>
      <category>sso</category>
    </item>
  </channel>
</rss>
