<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title> - Blog</title>
    <link rel="self" type="application/atom+xml" href="https://timonier.eu/blog/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://timonier.eu/blog/"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-08-29T00:00:00+00:00</updated>
    <id>https://timonier.eu/blog/atom.xml</id>
    <entry xml:lang="en">
        <title>Moving off Auth0 or Clerk to an EU-resident identity provider</title>
        <published>2026-08-29T00:00:00+00:00</published>
        <updated>2026-08-29T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://timonier.eu/blog/eu-resident-alternative-to-auth0-and-clerk/"/>
        <id>https://timonier.eu/blog/eu-resident-alternative-to-auth0-and-clerk/</id>
        
        <content type="html" xml:base="https://timonier.eu/blog/eu-resident-alternative-to-auth0-and-clerk/">&lt;h1 id=&quot;moving-off-auth0-or-clerk-to-an-eu-resident-identity-provider&quot;&gt;Moving off Auth0 or Clerk to an EU-resident identity provider&lt;&#x2F;h1&gt;
&lt;p&gt;The conversation usually starts when somebody in legal or security asks where your end users&#x27; identity data lives, and &quot;we selected the Frankfurt region&quot; turns out not to be the answer they were after.&lt;&#x2F;p&gt;
&lt;p&gt;A US-headquartered provider can be compelled under US law to hand over data it controls, and the location of the disks isn&#x27;t what decides that. It&#x27;s a question about control, which an availability-zone dropdown doesn&#x27;t answer. There&#x27;s a second question worth putting to any vendor including us: your region setting governs the user store, so what governs the configuration, the admin console, the audit records, the support tooling? Users in Frankfurt and a control surface somewhere else is a common shape, and most vendors will tell you which they have if you ask.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-the-data-actually-sits&quot;&gt;Where the data actually sits&lt;&#x2F;h2&gt;
&lt;p&gt;Our data plane holds end-user identity (user records, sessions, refresh tokens, magic-link attempts, TOTP enrolments) and serves every end-user request without a call through the control-plane backend. That covers sign in, verify, refresh, log out, token exchange and JWKS.&lt;&#x2F;p&gt;
&lt;p&gt;The auth control plane is the configuration source of truth: apps, redirect URIs, signup toggles, mailer config, branding, per-app signing-key records, the operator audit trail. It isn&#x27;t the system of record for end-user identity.&lt;&#x2F;p&gt;
&lt;p&gt;Since anyone can claim to have separated their services, here is what actually holds the line:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cp_auth_control&lt;&#x2F;code&gt; and &lt;code&gt;cp_auth_data&lt;&#x2F;code&gt; are separate databases, owned by separate Postgres roles.&lt;&#x2F;li&gt;
&lt;li&gt;Where the data plane reads control-plane configuration it connects as a third role, &lt;code&gt;cp_auth_data_reader&lt;&#x2F;code&gt;, which holds column-scoped grants on four named tables and nothing else. Not table-wide, not schema-wide: it can read &lt;code&gt;apps(id, project_id, slug, status, signup_enabled)&lt;&#x2F;code&gt;, for instance, and not the &lt;code&gt;client_secret_hash&lt;&#x2F;code&gt; sitting in the same row.&lt;&#x2F;li&gt;
&lt;li&gt;Where those grants do cover secret material, it&#x27;s ciphertext. The reader can select an app&#x27;s wrapped signing key, because signing tokens is the data plane&#x27;s job, but the key that unwraps it is not in that database.&lt;&#x2F;li&gt;
&lt;li&gt;Connection pools are phantom-typed by plane in Rust, so a store that needs the control-plane pool will not accept the data-plane one. Wiring the wrong pool into the wrong service fails to compile rather than failing at runtime against the wrong database.&lt;&#x2F;li&gt;
&lt;li&gt;The control-plane backend stays off the data path. An operator viewing users in the dashboard has their browser call the data plane&#x27;s admin API directly, cross-origin, with a short-lived project-scoped token that the control plane exists to mint.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;That column-scoped grant is a direct read of the control-plane database, so the split isn&#x27;t a claim that the two databases never touch. What it buys is that the control-plane service is never in the request path and never holds end-user records.&lt;&#x2F;p&gt;
&lt;p&gt;Operator identity is the other thing to be clear about. Your team&#x27;s admin accounts, their sessions and their MFA enrolments all live in the control plane. The claim on this page is about end-user identity specifically.&lt;&#x2F;p&gt;
&lt;p&gt;On the hosted plan today both planes run in our infrastructure, so the split is about system-of-record and blast radius rather than about your users&#x27; data never reaching our network. The data plane is the piece built to be deployed onto your own infrastructure, which is something we set up with you rather than a download.&lt;&#x2F;p&gt;
&lt;p&gt;Our own deployment sends mail through a relay we run, on Postgres and Redis we run, rather than through third-party APIs for either. The site you&#x27;re reading loads no third-party CDN, font or analytics script, which you can check from your devtools right now.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-migration-is-mostly-oidc&quot;&gt;The migration is mostly OIDC&lt;&#x2F;h2&gt;
&lt;p&gt;We&#x27;re an OpenID Connect provider: discovery, JWKS, authorization code flow with PKCE, confidential and public clients. One limit to know before you plan around it: a public client can only register loopback redirect URIs today, so a standalone browser SPA on a real domain isn&#x27;t one yet. Browser integrations go through the Next.js SDK&#x27;s server routes instead.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&#x2F;v1&#x2F;auth&#x2F;{project}&#x2F;apps&#x2F;{app}&#x2F;.well-known&#x2F;openid-configuration&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&#x2F;v1&#x2F;auth&#x2F;{project}&#x2F;apps&#x2F;{app}&#x2F;.well-known&#x2F;jwks.json&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&#x2F;v1&#x2F;auth&#x2F;{project}&#x2F;apps&#x2F;{app}&#x2F;authorize&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&#x2F;v1&#x2F;auth&#x2F;{project}&#x2F;apps&#x2F;{app}&#x2F;token&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;&#x2F;v1&#x2F;auth&#x2F;{project}&#x2F;apps&#x2F;{app}&#x2F;userinfo&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If your app reaches Auth0 through a generic OIDC client, the issuer URL and a client ID and secret are the minimum protocol-level changes, but claim mapping, scopes, logout behaviour and anything built against a vendor management API won&#x27;t come across for free. Stock Auth.js v5 completes the whole flow against us, discovery through session, with no platform-specific code in your app.&lt;&#x2F;p&gt;
&lt;p&gt;Clerk is a different shape of job because its drop-in React components are the integration. We ship a Next.js App Router SDK with the equivalent pieces (&lt;code&gt;AuthProvider&lt;&#x2F;code&gt;, &lt;code&gt;SignIn&lt;&#x2F;code&gt;, &lt;code&gt;SignUp&lt;&#x2F;code&gt;, &lt;code&gt;SignedIn&lt;&#x2F;code&gt;, &lt;code&gt;SignedOut&lt;&#x2F;code&gt;, &lt;code&gt;useUser&lt;&#x2F;code&gt;), plus a headless entry point for your own UI and a server module for route handlers and middleware. Budget it as a port.&lt;&#x2F;p&gt;
&lt;p&gt;Password hashes can&#x27;t come across at all, because there&#x27;s nowhere to put them: our users table has no password column, &lt;a href=&quot;&#x2F;blog&#x2F;passwordless-without-a-password-table&#x2F;&quot;&gt;by design&lt;&#x2F;a&gt;. Existing accounts import by email and go passwordless on their next sign-in. Decide that before the cutover, and tell your users sign-in has changed before they find out at the login screen.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-one-thing-we-don-t-do&quot;&gt;The one thing we don&#x27;t do&lt;&#x2F;h2&gt;
&lt;p&gt;Social login. No &quot;Sign in with Google&quot;, and it isn&#x27;t a gap we&#x27;re working through: it&#x27;s a decision recorded in an architecture decision record, and reversing it would take another one rather than a sprint. The &lt;a href=&quot;&#x2F;blog&#x2F;why-we-dont-do-sign-in-with-google&#x2F;&quot;&gt;reasoning is here&lt;&#x2F;a&gt;. If you run a consumer product where one-click signup drives conversion, that alone should disqualify us.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s next&lt;&#x2F;h2&gt;
&lt;p&gt;Web is what we support today, and native apps are the next thing we&#x27;re taking on. Custom-scheme redirects carry storage and credential questions we want to answer properly rather than quickly, so they&#x27;re a deliberate next step instead of an oversight. Dynamic client registration is on the same list; for now an operator configures clients, which at the team sizes we&#x27;re working with hasn&#x27;t been anyone&#x27;s bottleneck.&lt;&#x2F;p&gt;
&lt;p&gt;Worth knowing before you plan a cutover: none of this has to be a big bang. There&#x27;s a bulk import on the admin API, 500 users per batch, idempotent on project and email so you can re-run it without duplicating anyone. It preserves the provider and provider user ID each account came from, so an account imported from Auth0 still knows it was an Auth0 account. Paste a batch into the dashboard or curl it. Most teams run both systems side by side and move cohorts across at whatever pace they want, rather than picking a weekend and hoping.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;on-certifications&quot;&gt;On certifications&lt;&#x2F;h2&gt;
&lt;p&gt;Worth saying plainly, since it tends to come up early in a security review: as of August 2026 we hold no certifications and no audit is under way. Not ISO 27001, not SOC 2. At our size those follow the first customers who require them, and we&#x27;d rather tell you that than imply a posture we haven&#x27;t earned. If it&#x27;s a hard gate for you, say so early and we&#x27;ll talk about what it would take.&lt;&#x2F;p&gt;
&lt;p&gt;If you&#x27;re a European B2B product, an internal tool, or selling into public-sector or regulated buyers, the trade is that you give up social login and get an identity provider whose answer to &quot;where does the data live&quot; is structural instead of regional. Google sign-in is the one thing that isn&#x27;t coming. Everything else is a date, so it&#x27;s worth asking where we are on it.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;&#x2F;&quot;&gt;Timonier&lt;&#x2F;a&gt; is EU-resident authentication with a self-hostable data plane. Start with the &lt;a href=&quot;&#x2F;docs&#x2F;nextjs&#x2F;quickstart&#x2F;&quot;&gt;Next.js quickstart&lt;&#x2F;a&gt;, or email hello@timonier.eu about the data plane on your own infrastructure.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Passwordless sign-in without a password table</title>
        <published>2026-08-29T00:00:00+00:00</published>
        <updated>2026-08-29T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://timonier.eu/blog/passwordless-without-a-password-table/"/>
        <id>https://timonier.eu/blog/passwordless-without-a-password-table/</id>
        
        <content type="html" xml:base="https://timonier.eu/blog/passwordless-without-a-password-table/">&lt;h1 id=&quot;passwordless-sign-in-without-a-password-table&quot;&gt;Passwordless sign-in without a password table&lt;&#x2F;h1&gt;
&lt;p&gt;Here is every column in our &lt;code&gt;users&lt;&#x2F;code&gt; table:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;id                        uuid        &lt;&#x2F;span&gt;&lt;span class=&quot;z-storage&quot;&gt;primary key&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;project_id                uuid        &lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;not null&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;email                     citext      &lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;not null&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword z-storage z-type&quot;&gt;name                      text&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;created_at                &lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type z-keyword&quot;&gt;timestamptz not null&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;updated_at                &lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type z-keyword&quot;&gt;timestamptz not null&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;last_sign_in_at           &lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;timestamptz&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;banned_at                 &lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;timestamptz&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;external_provider         &lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;text&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;external_provider_user_id &lt;&#x2F;span&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;text&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;unique&lt;&#x2F;span&gt;&lt;span&gt; (project_id, email)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No &lt;code&gt;password_hash&lt;&#x2F;code&gt;, no &lt;code&gt;salt&lt;&#x2F;code&gt;, no &lt;code&gt;password_reset_token&lt;&#x2F;code&gt;, no &lt;code&gt;failed_login_count&lt;&#x2F;code&gt;. Argon2 shows up in exactly one place and it&#x27;s hashing OIDC client secrets, which are machine credentials and a different problem.&lt;&#x2F;p&gt;
&lt;p&gt;Dropping the column is the easy part.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;one-request-two-credentials&quot;&gt;One request, two credentials&lt;&#x2F;h2&gt;
&lt;p&gt;A sign-in request generates two forms of the same credential: a token of 32 random bytes, base64url, which goes into the emailed link, and a six-digit code for when that link opens in the wrong browser on someone&#x27;s phone. Both are hashed before storage.&lt;&#x2F;p&gt;
&lt;p&gt;They have to die together. Two forms of one credential, not two credentials. Store them independently and you&#x27;ve built a system where an intercepted link still works after the user has already signed in with the code.&lt;&#x2F;p&gt;
&lt;p&gt;The token is stored as a keyed BLAKE3 hash, with a ten-minute TTL. Configure it outside one to thirty minutes and the process refuses to boot rather than clamping quietly. Hashing something with a ten-minute life looks like theatre until you notice that ten minutes is the expiry and not the retention: expired rows sit there until cleanup runs, so a snapshot taken at an awkward moment holds live credentials for everyone who was mid-sign-in. The likelier exposure was never the database anyway. It&#x27;s a log line, an error report with the row attached, a support tool that renders a table.&lt;&#x2F;p&gt;
&lt;p&gt;Keys are per-credential-class and the process won&#x27;t boot unless all six are byte-distinct: the master key, the magic-link key, the refresh-token key, the authorization-code key, the hosted-login key and the recovery-code key. That check is there because reusing one key across classes is what happens during a rushed deployment and is invisible afterwards.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;single-use-enforced-twice&quot;&gt;Single-use, enforced twice&lt;&#x2F;h2&gt;
&lt;p&gt;In Redis it&#x27;s one Lua script: fetch the presented form, compare its hash against the stored one, and on a match &lt;code&gt;DEL&lt;&#x2F;code&gt; both the token key and the code key in the same body, so redeeming either kills the other.&lt;&#x2F;p&gt;
&lt;p&gt;The companion write is also a single Lua body rather than &lt;code&gt;MULTI&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;EXEC&lt;&#x2F;code&gt;, and the reason is specific to that operation: it&#x27;s a conditional two-key set whose &lt;code&gt;EXISTS&lt;&#x2F;code&gt; predicates aren&#x27;t all-or-nothing across a multiplexed connection, so a partial outcome leaves one form live and the other missing.&lt;&#x2F;p&gt;
&lt;p&gt;Postgres carries the second guard, a conditional update inside the completion transaction:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code data-lang=&quot;sql&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;update&lt;&#x2F;span&gt;&lt;span&gt; end_user_sign_in_attempts&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;   set status =&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; &amp;#39;complete&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt; where&lt;&#x2F;span&gt;&lt;span&gt; id &lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;=&lt;&#x2F;span&gt;&lt;span&gt; $&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;   and status =&lt;&#x2F;span&gt;&lt;span class=&quot;z-string&quot;&gt; &amp;#39;needs_first_factor&amp;#39;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;rows_affected()&lt;&#x2F;code&gt; of 0 means somebody got there first, so the transaction rolls back and returns &lt;code&gt;AlreadyComplete&lt;&#x2F;code&gt;. What matters is that there&#x27;s no &lt;code&gt;SELECT&lt;&#x2F;code&gt; to check the status before the &lt;code&gt;UPDATE&lt;&#x2F;code&gt;; read-then-check-then-write leaves a window exactly wide enough for a double-click on a slow connection. TOTP replay and recovery-code consume use the same conditional shape. Refresh rotation does read the row first, because reuse detection has to know that a presented token was already spent; the conditional update sits behind that as the race backstop.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;six-digits-is-twenty-bits&quot;&gt;Six digits is twenty bits&lt;&#x2F;h2&gt;
&lt;p&gt;A six-digit code carries about 20 bits of entropy, which on its own is guessable. It survives because five attempts per sign-in attempt is the cap and the sixth submission moves the whole attempt to &lt;code&gt;failed&lt;&#x2F;code&gt;, so the code you were attacking stops existing.&lt;&#x2F;p&gt;
&lt;p&gt;Rate-limit counters in Redis sit around that:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Limit&lt;&#x2F;th&gt;&lt;th&gt;Keyed on&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Send rate, per minute and per hour&lt;&#x2F;td&gt;&lt;td&gt;app + email address&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Send rate, per hour&lt;&#x2F;td&gt;&lt;td&gt;app + client IP&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Second-factor attempts, per attempt&lt;&#x2F;td&gt;&lt;td&gt;the sign-in attempt&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Second-factor attempts, per user&lt;&#x2F;td&gt;&lt;td&gt;user + app&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The email buckets key on the address so flooding one mailbox stays bounded regardless of how many IPs you have, and the IP bucket keys on the client address so enumerating many addresses from one host stays bounded regardless of how many mailboxes you try. Neither covers both attacks alone.&lt;&#x2F;p&gt;
&lt;p&gt;Every step of a sign-in runs whether or not the account exists. The three rate-limit counters are charged before anything is looked up, both credentials are generated and hashed before we know whether there&#x27;s a user to send them to, and the attempt row and the Redis write happen either way. Only at the end does the mailer get a &lt;code&gt;Deliver&lt;&#x2F;code&gt; or a &lt;code&gt;Skip&lt;&#x2F;code&gt;, and both spawn and return without being awaited. Skipping the work for unknown addresses makes the response measurably faster for them, which is an enumeration oracle, and it&#x27;s what you get by writing the function the obvious way with an early return on &quot;user not found&quot;. Two tests assert that the known and unknown branches produce an identical sequence of calls.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;a-bypass-between-two-endpoints&quot;&gt;A bypass between two endpoints&lt;&#x2F;h2&gt;
&lt;p&gt;Sign-in and sign-up are separate endpoints and TOTP is a second factor on sign-in, so the obvious implementation gates the second factor on the sign-in endpoint.&lt;&#x2F;p&gt;
&lt;p&gt;But &lt;code&gt;&#x2F;sign-up&lt;&#x2F;code&gt; upserts on &lt;code&gt;(project_id, email)&lt;&#x2F;code&gt;. A user who already exists and has TOTP enrolled can go through sign-up, get a magic link for the same account, and land in a session without being asked for a second factor. No exploit involved, just a documented public endpoint.&lt;&#x2F;p&gt;
&lt;p&gt;The gate has to key off the resolved user instead of the endpoint the request arrived on. Ours returns &lt;code&gt;totp_confirmed&lt;&#x2F;code&gt; from inside the same open transaction that resolves the user, and branches there, so both endpoints pass through one decision. What made it easy to miss is that the endpoint-based version behaves correctly under the tests you write per endpoint. Sign in with 2FA and you&#x27;re prompted, without and you&#x27;re not, enrol and disable and re-enrol all behave. You only see it if you go looking at the seam.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-you-still-have-to-build&quot;&gt;What you still have to build&lt;&#x2F;h2&gt;
&lt;p&gt;Four things, none of which the missing password column saves you from:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;TOTP secrets are real secrets at rest. Ours are AES-GCM encrypted with the user&#x27;s ID as additional authenticated data, so a ciphertext copied onto another user&#x27;s row fails to unwrap instead of quietly working.&lt;&#x2F;li&gt;
&lt;li&gt;Recovery codes are ten 160-bit values hashed under their own key, single-use through the same conditional update as everything else.&lt;&#x2F;li&gt;
&lt;li&gt;A 30-second TOTP window with one step of skew either side leaves a code valid for 90 seconds, so you record the last step used and reject anything not strictly newer.&lt;&#x2F;li&gt;
&lt;li&gt;Sessions still expire and still get stolen. Short-lived access token, rotating refresh token, and reuse detection that revokes the session when an already-used token comes back.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;&#x2F;&quot;&gt;Timonier&lt;&#x2F;a&gt; is EU-resident authentication with a self-hostable data plane. The &lt;a href=&quot;&#x2F;docs&#x2F;nextjs&#x2F;quickstart&#x2F;&quot;&gt;Next.js quickstart&lt;&#x2F;a&gt; gets a real user signed in.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>White or black? Deriving readable label text from any brand colour</title>
        <published>2026-08-29T00:00:00+00:00</published>
        <updated>2026-08-29T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://timonier.eu/blog/white-or-black-text-on-any-brand-colour/"/>
        <id>https://timonier.eu/blog/white-or-black-text-on-any-brand-colour/</id>
        
        <content type="html" xml:base="https://timonier.eu/blog/white-or-black-text-on-any-brand-colour/">&lt;h1 id=&quot;white-or-black-deriving-readable-label-text-from-any-brand-colour&quot;&gt;White or black? Deriving readable label text from any brand colour&lt;&#x2F;h1&gt;
&lt;p&gt;Operators using our hosted sign-in page can set a brand colour. The validator checked that the value was six hex digits, which is all it was ever asked to check, and the page rendered &lt;code&gt;color:#fff&lt;&#x2F;code&gt; over &lt;code&gt;background:var(--cp-brand)&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Set your brand colour to &lt;code&gt;#ffd700&lt;&#x2F;code&gt; and the label on the primary button ships at 1.40:1 against its fill. WCAG AA asks for 4.5:1 on normal-sized text. Our e2e fixture uses &lt;code&gt;#3366cc&lt;&#x2F;code&gt;, which lands at 5.4:1, so every screenshot we had ever taken passed.&lt;&#x2F;p&gt;
&lt;p&gt;We looked at rejecting light colours at authoring time and at darkening the fill until the label passed. Both are worse than the bug. The first turns a format validator into a policy engine that has to explain itself in a form error, and the second silently repaints a customer&#x27;s brand. Keeping the fill and deriving the ink from it turns out to be provably correct anyway, so neither is necessary.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-derivation&quot;&gt;The derivation&lt;&#x2F;h2&gt;
&lt;p&gt;Compute the fill&#x27;s relative luminance &lt;code&gt;L&lt;&#x2F;code&gt;, then take the higher-contrast of pure white and pure black:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;contrast(white, L) = 1.05 &#x2F; (L + 0.05)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;contrast(black, L) = (L + 0.05) &#x2F; 0.05&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One falls as &lt;code&gt;L&lt;&#x2F;code&gt; rises and the other climbs, so the worst case for taking the better of the two is where they cross:&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;1.05 &#x2F; (L + 0.05) = (L + 0.05) &#x2F; 0.05&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;(L + 0.05)²       = 1.05 × 0.05&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt; L + 0.05         = √0.0525 ≈ 0.22913&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Both ratios come out at 4.5826:1 there, which clears AA for every valid sRGB fill under the WCAG formula. &lt;code&gt;#ffd700&lt;&#x2F;code&gt; gets black at 14.97:1, &lt;code&gt;#3366cc&lt;&#x2F;code&gt; gets white at 5.37:1.&lt;&#x2F;p&gt;
&lt;p&gt;The part that surprised me is how little slack that leaves. Pure black looks harsh next to a soft brand fill and the obvious move is to reach for the neutral already in your token file, which for us is &lt;code&gt;#111827&lt;&#x2F;code&gt;. Run it through the same algebra and the worst case drops to about 4.13:1. It only fails across a band of mid-luminance fills, so a team whose own brand sits outside that band will never see it, and neither will their fixtures. There is a comment in our source saying not to substitute a softer ink, with the number, because somebody was going to try.&lt;&#x2F;p&gt;
&lt;pre class=&quot;giallo z-code&quot;&gt;&lt;code data-lang=&quot;rust&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt; channel_to_linear&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(c&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; u8&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; f64&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    let&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; c&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; f64&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt;from&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(c)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 255.0&lt;&#x2F;span&gt;&lt;span&gt;;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; c&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;lt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.04045&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;        c&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 12.92&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;        ((c&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.055&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1.055&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt;powf&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;2.4&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt; relative_luminance&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(hex&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;: &amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; -&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; f64&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    let&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; byte&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; = |&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;range&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;:&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; std&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;ops&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;Range&lt;&#x2F;span&gt;&lt;span&gt;&amp;lt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;usize&lt;&#x2F;span&gt;&lt;span&gt;&amp;gt;&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;|&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;        hex&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt;get&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(range)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            .&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt;and_then&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;|&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;s&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;|&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt; u8&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;::&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt;from_str_radix&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(s,&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 16&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;.&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt;ok&lt;&#x2F;span&gt;&lt;span&gt;())&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;            .&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt;unwrap_or&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;0&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    };&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-constant&quot;&gt;    0.2126&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt; channel_to_linear&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt;byte&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;1&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;..&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;3&lt;&#x2F;span&gt;&lt;span&gt;))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.7152&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt; channel_to_linear&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt;byte&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;3&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;..&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;5&lt;&#x2F;span&gt;&lt;span&gt;))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;        +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.0722&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; *&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt; channel_to_linear&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt;byte&lt;&#x2F;span&gt;&lt;span&gt;(&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;5&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;..&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt;7&lt;&#x2F;span&gt;&lt;span&gt;))&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;fn&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt; ink_for_fill&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(hex&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt;: &amp;amp;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;str&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; -&amp;gt; &amp;amp;&lt;&#x2F;span&gt;&lt;span&gt;&amp;#39;&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name&quot;&gt;static str&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-storage z-type&quot;&gt;    let&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; l&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; =&lt;&#x2F;span&gt;&lt;span class=&quot;z-entity z-name z-function&quot;&gt; relative_luminance&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt;(hex);&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-keyword&quot;&gt;    if&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 1.05&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; (l&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.05&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &amp;gt;=&lt;&#x2F;span&gt;&lt;span class=&quot;z-variable z-other&quot;&gt; (l&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; +&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.05&lt;&#x2F;span&gt;&lt;span&gt;)&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; &#x2F;&lt;&#x2F;span&gt;&lt;span class=&quot;z-constant&quot;&gt; 0.05&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;        &amp;quot;#ffffff&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;span class=&quot;z-keyword&quot;&gt; else&lt;&#x2F;span&gt;&lt;span&gt; {&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-string&quot;&gt;        &amp;quot;#000000&amp;quot;&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;&#x2F;span&gt;&lt;&#x2F;span&gt;&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The sRGB breakpoint is &lt;code&gt;0.04045&lt;&#x2F;code&gt;. Plenty of copies of this snippet use &lt;code&gt;0.03928&lt;&#x2F;code&gt;, which was the older value; no 8-bit channel falls between the two so it changes no output. The &lt;code&gt;unwrap_or(0)&lt;&#x2F;code&gt; is there so the function can&#x27;t panic, not as a safety property. What makes it safe is that the caller only ever passes a &lt;code&gt;BrandColor&lt;&#x2F;code&gt; that has already been validated.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-focus-ring&quot;&gt;The focus ring&lt;&#x2F;h2&gt;
&lt;p&gt;Fixing the button surfaced something worse. The focus indicator on the email and code inputs was derived from the brand colour too, sitting on top of &lt;code&gt;outline: none&lt;&#x2F;code&gt;. At the light end of the range a credential field had no visible focus ring at all, which means a keyboard user typing their sign-in code could not see where they were.&lt;&#x2F;p&gt;
&lt;p&gt;That one is not solvable by choosing between two constants, because the ring sits on the card background rather than on the brand fill, so the luminance argument above doesn&#x27;t reach it. It got a floor keyed to our own text token instead, measuring between 13:1 and 17.7:1 against the card and the page background in both light and dark. The brand tint survives as the input&#x27;s border colour and a soft halo. It is decoration now and never the only signal.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;BrandColor&lt;&#x2F;code&gt; was never the problem, incidentally. It validates format, which is all it should know about. The renderer took that correct value and paired it with a hardcoded &lt;code&gt;#fff&lt;&#x2F;code&gt; that happened to work for the colours in our fixtures.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;This is one of the fixes behind &lt;a href=&quot;&#x2F;&quot;&gt;Timonier&lt;&#x2F;a&gt;&#x27;s hosted sign-in page, which operators can brand with their own logo and colour. Whatever colour they pick, the label on it holds WCAG AA.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Why we don&#x27;t do &quot;Sign in with Google&quot;</title>
        <published>2026-08-29T00:00:00+00:00</published>
        <updated>2026-08-29T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://timonier.eu/blog/why-we-dont-do-sign-in-with-google/"/>
        <id>https://timonier.eu/blog/why-we-dont-do-sign-in-with-google/</id>
        
        <content type="html" xml:base="https://timonier.eu/blog/why-we-dont-do-sign-in-with-google/">&lt;h1 id=&quot;why-we-don-t-do-sign-in-with-google&quot;&gt;Why we don&#x27;t do &quot;Sign in with Google&quot;&lt;&#x2F;h1&gt;
&lt;p&gt;Evaluators ask about social login early and the answer is no. It&#x27;s ruled out by an architecture decision record rather than parked in a backlog, and since that costs us deals it seems worth explaining.&lt;&#x2F;p&gt;
&lt;p&gt;Worth clearing up an ambiguity first, since two opposite things get called &quot;supporting Google&quot;. We are an identity provider. Your app redirects to us, we host the sign-in page, mint an authorization code, issue an &lt;code&gt;id_token&lt;&#x2F;code&gt;, publish discovery and JWKS, and a stock OIDC client works against us with no platform-specific code; we drive Auth.js v5 through the whole flow in our test suite. The direction we don&#x27;t go is the other one, where the user clicks a button, we bounce them to Google, and we take Google&#x27;s word for who they are.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;federation-puts-a-us-company-on-the-sign-in-path&quot;&gt;Federation puts a US company on the sign-in path&lt;&#x2F;h2&gt;
&lt;p&gt;Our hard constraint is that no US-headquartered third party sits on the runtime path for end-user identity. Picking a Frankfurt region doesn&#x27;t satisfy it, since the constraint is about who controls the service.&lt;&#x2F;p&gt;
&lt;p&gt;Google, GitHub&#x2F;Microsoft, Apple and Facebook are all US-headquartered. Federate sign-in to any of them and on every authentication the user&#x27;s browser gets redirected to a US company, which learns that this person is signing into your product, from this address, now. It happens before we see the user at all, so there&#x27;s no setting on our side that changes it.&lt;&#x2F;p&gt;
&lt;p&gt;Some teams need the feature and can&#x27;t ship without it. If you run a consumer app where a real share of signups abandon at the email step, one-click social is worth money you shouldn&#x27;t give up for our architectural preferences, and you should use somebody else. The bet is that for B2B products, internal tools, and anything sold to European buyers with a DPO involved, the same feature that lifts consumer conversion is the one that complicates a procurement review.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-smaller-reason&quot;&gt;The smaller reason&lt;&#x2F;h2&gt;
&lt;p&gt;Social login isn&#x27;t a feature you add once. Every provider is an OAuth app registration, a client secret on a rotation schedule, and its own behaviour around scopes and token shapes and email verification. The verification semantics are the genuinely annoying part, because &quot;this address is verified&quot; means different things at different providers and the differences aren&#x27;t collected anywhere.&lt;&#x2F;p&gt;
&lt;p&gt;Account linking is the part I&#x27;d actually worry about. Someone signs up as &lt;code&gt;alice@example.com&lt;&#x2F;code&gt; by email; months later she clicks &quot;Sign in with GitHub&quot; and GitHub asserts &lt;code&gt;alice@example.com&lt;&#x2F;code&gt;. Link on that assertion and any weakness in the provider&#x27;s verification becomes an account-takeover path on your platform. Refuse to link and she&#x27;s locked out of her own data. Every vendor offering social login has picked an answer, and the answers are all fine, but it&#x27;s a surface that grows with each provider you add and we don&#x27;t have it. There&#x27;s one credential path here: an emailed link and code, with TOTP on top if you want it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-door-is-not-nailed-shut&quot;&gt;The door is not nailed shut&lt;&#x2F;h2&gt;
&lt;p&gt;Federating to an EU-headquartered IdP is conceivable, and it removes the jurisdiction objection without touching the rest of the review: sub-processor status, what the assertion actually proves, how linking gets handled. A generic OIDC relying-party mode pointed at a customer&#x27;s own Keycloak is conceivable too.&lt;&#x2F;p&gt;
&lt;p&gt;Both would need their own decision record and a customer asking for a specific one. Building the abstraction first, on the theory that somebody will eventually want it, is how you end up maintaining a federation layer with one user and four security advisories.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;&lt;a href=&quot;&#x2F;&quot;&gt;Timonier&lt;&#x2F;a&gt; is EU-resident authentication with a self-hostable data plane. There&#x27;s a &lt;a href=&quot;&#x2F;docs&#x2F;nextjs&#x2F;quickstart&#x2F;&quot;&gt;Next.js quickstart&lt;&#x2F;a&gt; if you want to see the flow.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
