auth: read one login off one configurable header
A display name chose the book, and a name is free to move;
one header carries the login now, the thing a proxy can prove.
Remote-User unless a deployment names another to be read —
the digest hashes the login, so stale tokens get 410 instead.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change tsmnnqskkwznrlxunwqyppsxutrzwrwz
commit afac4b0b75d07263afbdb914ba392bc01f40933a
author Alpha Chen <alpha@kejadlen.dev>
date
parent tpnxpkxq
diff --git a/AGENTS.md b/AGENTS.md
index c73709b..5ed74fe 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,11 +93,12 @@ not a fixture; edit a `.vcf` to change what the replay serves.
   `DavXml`, whose methods are the only elements the server can send. Inside
   an element's block, `it` is the builder, not an enclosing block's value,
   so name the outer block's parameter.
-- Every request needs `Tailscale-User-Login` and `Tailscale-User-Name`
-  headers or it gets a 401, so a bare `curl` against `rake dev` is
-  refused until you pass both. The
-  security of that rests on the app being reachable only through
-  `tailscale serve` — never bind it to anything but localhost.
+- Every request needs the identity header or it gets a 401, so a bare
+  `curl` against `rake dev` is refused until you pass `Tailscale-Login`,
+  which is what that task sets `PRO_TACTS_IDENTITY_HEADER` to
+  (`Remote-User` is the default everywhere else). The security of that
+  rests on the app being reachable only through a proxy that writes the
+  header itself — never bind it to anything but localhost.
 - DAV exchanges that went wrong (a status of 400 or more but 401, a
   crash, or a report to Sentry) are written whole to `log/exchange.log`,
   each line prefixed by the id Sentry carries as the `exchange` tag.
diff --git a/README.md b/README.md
index fb13d65..e86d64e 100644
--- a/README.md
+++ b/README.md
@@ -57,20 +57,24 @@ PUT stores the submitted card verbatim (RFC 6352 section 6.3.2), and the
 change log the sync tokens count on is written with it, in one
 transaction.
 
-Requests are authenticated by the `Tailscale-User-Login` and
-`Tailscale-User-Name` headers that `tailscale serve` injects, which it
-strips from incoming requests so a client cannot forge them. A request
-without both gets a 401. That holds only
-while the app is reachable through serve alone — bind it to localhost.
+Requests are authenticated by one request header carrying the
+requester's login: `Remote-User`, unless `PRO_TACTS_IDENTITY_HEADER`
+names another. The proxy in front of the app writes that header.
+`tailscale serve` writes `Tailscale-User-Login` and strips it from
+incoming requests, and a Caddy site's `header_up` overwrites whatever
+arrived, so a client cannot forge either one. A request without a login
+gets a 401. That holds only while the app is reachable through such a
+proxy alone — bind it to localhost.
 Tailscale documents two cases that carry no identity and so cannot get in:
 Funnel traffic, which is public, and traffic from tagged devices.
 
 Each user syncs a book of their own rather than every card: the members
-of the group `sync:*`, which everyone gets, and of `sync:<name>`, where
-the name is the user's Tailscale display name in any case. A card a
-client creates joins `sync:*`, so everyone gets it until someone takes
-it out. See `docs/plans/2026-09-12-per-user-books.md` and
-`docs/plans/2026-09-15-client-creates-join-everyone.md`.
+of the group `sync:*`, which everyone gets, and of `sync:<login>`,
+matched in any case. A card a client creates joins `sync:*`, so everyone
+gets it until someone takes it out. See
+`docs/plans/2026-09-12-per-user-books.md`,
+`docs/plans/2026-09-15-client-creates-join-everyone.md`, and
+`docs/plans/2026-09-15-identity-from-one-header.md`.
 
 DAV exchanges that go wrong — a status of 400 or more other than the 401
 above, a crash, or a request that reported to Sentry — are written whole
diff --git a/Rakefile b/Rakefile
index aa19bed..24fa754 100644
--- a/Rakefile
+++ b/Rakefile
@@ -30,6 +30,12 @@ task :dev do
   ENV["PRO_TACTS_EXCHANGE_LOG"] ||= "log/dev.log"
   File.truncate("log/dev.log", 0) if File.exist?("log/dev.log")
 
+  # No proxy in front of a dev server, so the identity header is
+  # whatever a client passes by hand. Tailscale-Login rather than the
+  # Remote-User default because a dev session is testing what the
+  # tailnet deployment does (ProTacts::Config#identity_header).
+  ENV["PRO_TACTS_IDENTITY_HEADER"] ||= "Tailscale-Login"
+
   Dir.mktmpdir("pro-tacts-dev") do |dir|
     data_dir = Pathname.new(dir)
     ENV["PRO_TACTS_DATA_DIR"] = data_dir.to_s
diff --git a/docs/plans/2026-09-15-identity-from-one-header.md b/docs/plans/2026-09-15-identity-from-one-header.md
new file mode 100644
index 0000000..1d43026
--- /dev/null
+++ b/docs/plans/2026-09-15-identity-from-one-header.md
@@ -0,0 +1,75 @@
+# Identity from one header
+
+2026-09-15. A request's identity is one login, read off one request
+header the deployment names. It replaces the pair of Tailscale headers
+and the display name that keyed a book.
+
+## What went wrong
+
+The deploy at `https://contacts/` sits behind Caddy with
+caddy-tailscale, not behind `tailscale serve`, and its site sent:
+
+```caddyfile
+header_up Tailscale-User-Login {http.auth.user.tailscale_login}
+header_up Tailscale-User-Name  {http.auth.user.tailscale_user}
+```
+
+caddy-tailscale's README gives `tailscale_user` as "same as `user.id`",
+the email-ish user ID, and `tailscale_name` as the display name. So the
+header the book was keyed on carried a login. `Store#book` looked for a
+group named `sync:alpha@kejadlen.dev`, found none, and every user's book
+collapsed to the members of `sync:*` — one card of four, which reads
+from macOS Contacts as a sync that stopped working.
+
+The server never noticed. A name that matches no group is an ordinary
+empty book, and nothing in the app says whose book it just served.
+
+## One value
+
+Identity is now a single string, the requester's login, and there is no
+display name in the model. `ProxyAuth.login` reads it, `Web` holds it as
+`@login`, and it does the two jobs the old pair split: it keys
+`sync:<login>`, and it fills `CardDAVUsername` in the profile `/setup`
+serves.
+
+The module doing the reading is `ProxyAuth` now, not `TailscaleAuth`
+(the name `docs/plans/2026-09-12-per-user-books.md` still uses): the
+header it reads is a setting, and the proxy writing it is the
+deployment's to choose.
+
+A login is the identifier a display name never was. The three
+consequences `docs/plans/2026-09-12-per-user-books.md` accepted under
+"Whose name" — two users sharing a book, a rename moving a user off
+their group, a user typing someone else's name to take their book — are
+all gone with it. What it costs is group names: `sync:alpha@example.com`
+where `sync:Alpha Chen` read as English. A setting for that name is the
+next piece of work, not part of this one.
+
+## Which header
+
+`ProTacts.config.identity_header` names the header, and
+`PRO_TACTS_IDENTITY_HEADER` sets it. The default is `Remote-User`, what
+a reverse proxy conventionally writes the authenticated user to; the
+`docs` site in the same Caddyfile already sends it to another backend.
+`rake dev` sets `Tailscale-Login`, because a dev session has no proxy in
+front of it and is testing what the tailnet deployment does.
+
+The header is trustworthy for the same reason the pair was. `tailscale
+serve` strips `Tailscale-User-Login` from an incoming request before it
+writes its own, and Caddy's `header_up` with no `+` prefix sets the
+field, overwriting whatever arrived. Neither guarantee survives reaching
+the app directly, so it still must not listen anywhere but localhost.
+
+An unset header name is not a case the config handles: the default
+covers an absent variable, and a variable set to nothing names no header
+and 401s every request, which is the loud failure this repo prefers to a
+quiet fallback.
+
+## What a deployment has to change
+
+The sync token carries the first 16 hex digits of the SHA-256 of the
+login (`Web#book_digest`), so moving from a display name to a login
+changes every token. A client presenting an old one gets the 410 and
+`DAV:valid-sync-token` that `docs/plans/2026-09-12-per-user-books.md`
+specified for a renamed user, and resyncs from scratch. That is the
+designed path, and it is what the recorded sessions' fixtures moved for.
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index e37e755..9ed510a 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -61,6 +61,17 @@ module ProTacts
       Pathname.new(@env.fetch("PRO_TACTS_EXCHANGE_LOG", data_dir / "log/exchange.log"))
     end
 
+    # The request header a requester's login is read off,
+    # PRO_TACTS_IDENTITY_HEADER — the whole of the identity this app has
+    # (see ProTacts::ProxyAuth). A deployment names the header its own
+    # proxy writes: `tailscale serve` writes Tailscale-User-Login, and a
+    # Caddy site writes whatever its header_up names. The default is what
+    # a reverse proxy conventionally writes the authenticated user to.
+    #: () -> String
+    def identity_header
+      @env.fetch("PRO_TACTS_IDENTITY_HEADER", "Remote-User")
+    end
+
     # The CardDAV account's display name, PRO_TACTS_PROFILE_NAME — what
     # a device labels the account it installs, and the one way to tell
     # two pro-tacts accounts apart on the same phone. nil when unset
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index 85a0062..4b589b4 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -45,11 +45,11 @@ module ProTacts
     end
 
     # The server ignores the username and password: identity comes from
-    # the Tailscale headers that serve injects (see
-    # ProTacts::TailscaleAuth). /setup passes the requester's login
-    # anyway, so the account says which tailnet user it is for. The
-    # password stays a placeholder because the account form expects the
-    # field, and dropping it is untested.
+    # the header the proxy in front of it writes (see
+    # ProTacts::ProxyAuth). /setup passes the requester's login anyway,
+    # so the account says which tailnet user it is for. The password
+    # stays a placeholder because the account form expects the field,
+    # and dropping it is untested.
     #: (hostname: String, username: String) -> String
     def self.render(hostname:, username:)
       identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
diff --git a/lib/pro_tacts/tailscale_auth.rb b/lib/pro_tacts/proxy_auth.rb
similarity index 56%
rename from lib/pro_tacts/tailscale_auth.rb
rename to lib/pro_tacts/proxy_auth.rb
index 664535a..db23f22 100644
--- a/lib/pro_tacts/tailscale_auth.rb
+++ b/lib/pro_tacts/proxy_auth.rb
@@ -1,12 +1,17 @@
+require "pro_tacts"
+
 module ProTacts
-  # Who is asking, read off the identity headers Tailscale serve injects.
+  # Who is asking: the login on the one request header the proxy in front
+  # of this app writes it to, named by ProTacts.config.identity_header
+  # (docs/plans/2026-09-15-identity-from-one-header.md).
   #
-  # Serve sets Tailscale-User-Login and Tailscale-User-Name from the
-  # tailnet identity of the calling node, and strips both from incoming
-  # requests before proxying so a client cannot supply its own. That makes
-  # the headers trustworthy, but only behind serve: reached directly, the
-  # app trusts whatever it is handed. The app must not be listening
-  # anywhere but localhost.
+  # `tailscale serve` writes Tailscale-User-Login from the tailnet
+  # identity of the calling node and strips it from incoming requests
+  # before proxying, and a Caddy site's `header_up` sets the header it
+  # names, overwriting whatever arrived. Either way a client cannot
+  # supply its own. That makes the header trustworthy, but only behind
+  # such a proxy: reached directly, the app trusts whatever it is
+  # handed. The app must not be listening anywhere but localhost.
   #
   # A request that names nobody is refused at the top of Web's route
   # (Web#unauthorized). That covers the two cases Tailscale documents as
@@ -14,18 +19,10 @@ module ProTacts
   # tagged devices. A family device that gets tagged will start seeing
   # 401s.
   #
-  # Any tailnet identity is accepted. Getting onto the tailnet is the access
-  # control; the name only picks which address book a client syncs
-  # (docs/plans/2026-09-12-per-user-books.md).
-  module TailscaleAuth
-    LOGIN_HEADER = "HTTP_TAILSCALE_USER_LOGIN"
-    NAME_HEADER = "HTTP_TAILSCALE_USER_NAME"
-
-    # Who is asking, both headers decoded. The signature is in
-    # sig/pro_tacts/tailscale_auth.rbs.
-    # @rbs skip
-    Identity = Data.define(:login, :name)
-
+  # Any login the proxy vouches for is accepted. Getting onto the tailnet
+  # is the access control; the login only picks which address book a
+  # client syncs (docs/plans/2026-09-12-per-user-books.md).
+  module ProxyAuth
     # One RFC 2047 encoded-word (section 2), and the whitespace after it
     # when another follows: section 6.2 has that whitespace dropped, so a
     # long value split across words decodes whole.
@@ -33,14 +30,20 @@ module ProTacts
     ENCODED_RUN = /#{ENCODED_WORD}(?:[ \t]+(?=#{ENCODED_WORD}))?/ #: Regexp
     private_constant :ENCODED_WORD, :ENCODED_RUN
 
-    # The identity on a request, or nil for one that names nobody.
-    #: (Rack::env env) -> Identity?
-    def self.identity(env)
-      login = decode(env[LOGIN_HEADER].to_s)&.strip
-      name = decode(env[NAME_HEADER].to_s)&.strip
-      return if login.nil? || login.empty? || name.nil? || name.empty?
+    # The login on a request, or nil for one that names nobody. The
+    # header is an argument so a caller can read one this deployment is
+    # not configured for; every caller in the app takes the default.
+    #: (Rack::env env, ?header: String) -> String?
+    def self.login(env, header: ProTacts.config.identity_header)
+      login = decode(env[env_key(header)].to_s)&.strip
+      login unless login.nil? || login.empty?
+    end
 
-      Identity.new(login:, name:)
+    # Rack's spelling of a request header (the Rack SPEC's environment):
+    # upcased, dashes to underscores, HTTP_ in front.
+    #: (String header) -> String
+    def self.env_key(header)
+      "HTTP_#{header.upcase.tr("-", "_")}"
     end
 
     # A header value as serve writes it: Go's mime.QEncoding over the
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index d936e6a..d943c5d 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -172,7 +172,7 @@ module ProTacts
     GROUP_COLUMNS = [Sequel[:groups][:id], Sequel[:groups][:name]].freeze #: Array[untyped]
 
     # The group names that choose what a client syncs: `sync:*` for
-    # everyone, `sync:<display name>` for one user
+    # everyone, `sync:<login>` for one user
     # (docs/plans/2026-09-12-per-user-books.md). Unique like every
     # group's name (db/migrations/008_group_names.rb).
     SYNC_PREFIX = "sync:" #: String
@@ -357,13 +357,13 @@ module ProTacts
     end
 
     # The cards one user's client syncs: every member of `sync:*` and of
-    # `sync:<name>`, each once (docs/plans/2026-09-12-per-user-books.md).
-    # The name matches in any case, folded here in Ruby because SQLite's
+    # `sync:<login>`, each once (docs/plans/2026-09-12-per-user-books.md).
+    # The login matches in any case, folded here in Ruby because SQLite's
     # lower() folds ASCII alone; the prefix only as #sync_name? reads it,
     # since no other group's moves are logged.
-    #: (String name) -> Set[String]
-    def book(name)
-      own = "#{SYNC_PREFIX}#{name}"
+    #: (String login) -> Set[String]
+    def book(login)
+      own = "#{SYNC_PREFIX}#{login}"
       ids = groups.where(Sequel.function(:glob, "#{SYNC_PREFIX}*", :name)).all
         .select { |group| (label = group.fetch(:name).to_s) == EVERYONE || label.casecmp?(own) }
         .map { it.fetch(:id) }
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index a51dd5f..f96b50a 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -11,14 +11,14 @@ require "pro_tacts/contact"
 require "pro_tacts/exchange_log"
 require "pro_tacts/refusal_alerts"
 require "pro_tacts/store"
-require "pro_tacts/tailscale_auth"
+require "pro_tacts/proxy_auth"
 require "roda/plugins/dav_verbs"
 
 module ProTacts
   class Web < Roda
     # @rbs @contacts: Array[Contact]?
     # @rbs @ctag: String?
-    # @rbs @identity: TailscaleAuth::Identity
+    # @rbs @login: String
     # @rbs @book: Set[String]?
 
     # The vendored Gloss CSS and the admin app's own stylesheet (see
@@ -79,8 +79,8 @@ module ProTacts
     # what the trunk sets they see.
     route do |r|
       # Every request names a tailnet user or is refused, the static
-      # files included (ProTacts::TailscaleAuth).
-      @identity = TailscaleAuth.identity(r.env) || unauthorized(r)
+      # files included (ProTacts::ProxyAuth).
+      @login = ProxyAuth.login(r.env) || unauthorized(r)
 
       r.public
       r.hash_branches
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index 3a248bb..09e5ba4 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -322,7 +322,7 @@ module ProTacts
 
     #: () -> Set[String]
     def book
-      @book ||= store.book(@identity.name)
+      @book ||= store.book(@login)
     end
 
     # One member of the requester's book, or nil for a card outside it
@@ -340,20 +340,20 @@ module ProTacts
 
     # Sync tokens are opaque to the client (RFC 6578 section 3); the URI
     # form is conventional. Built on the ctag so a client polling either
-    # one sees changes at the same points, and on the requester's name so
-    # that a token is refused by any other book, the one a rename leaves
-    # a user with included (docs/plans/2026-09-12-per-user-books.md, "The
-    # wire").
+    # one sees changes at the same points, and on the requester's login
+    # so that a token is refused by any other book, the one a rename
+    # leaves a user with included (docs/plans/2026-09-12-per-user-books.md,
+    # "The wire").
     #: () -> String
     def sync_token
       "http://pro-tacts/sync/#{ctag}/#{book_digest}"
     end
 
     # The name half of a sync token: the first 16 hex digits of the
-    # SHA-256 of the requester's display name.
+    # SHA-256 of the requester's login.
     #: () -> String
     def book_digest
-      Digest::SHA256.hexdigest(@identity.name)[0, 16].to_s
+      Digest::SHA256.hexdigest(@login)[0, 16].to_s
     end
 
     # The whole of the PUT route: a private method because a Roda route
diff --git a/lib/pro_tacts/web/setup.rb b/lib/pro_tacts/web/setup.rb
index 219f291..68615a0 100644
--- a/lib/pro_tacts/web/setup.rb
+++ b/lib/pro_tacts/web/setup.rb
@@ -35,7 +35,7 @@ module ProTacts
       # is the point.
       r.get "carddav.mobileconfig" do
         response["Content-Type"] = "application/x-apple-aspen-config"
-        Profile.render(hostname: r.host, username: @identity.login)
+        Profile.render(hostname: r.host, username: @login)
       end
     end
   end
diff --git a/sig/pro_tacts/tailscale_auth.rbs b/sig/pro_tacts/tailscale_auth.rbs
deleted file mode 100644
index e3d6a2a..0000000
--- a/sig/pro_tacts/tailscale_auth.rbs
+++ /dev/null
@@ -1,13 +0,0 @@
-# Identity is a Data class, whose members the inline parser cannot read
-# (see sig/pro_tacts/contact.rbs). TailscaleAuth itself is an ordinary
-# module and its types are inline in tailscale_auth.rb.
-module ProTacts
-  module TailscaleAuth
-    class Identity
-      attr_reader login: String
-      attr_reader name: String
-
-      def self.new: (login: String, name: String) -> instance
-    end
-  end
-end
diff --git a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
index 3dbff83..2185e73 100644
--- a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
+++ b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
           </d:supported-report>
         </d:supported-report-set>
         <cs:getctag>19</cs:getctag>
-        <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
+        <d:sync-token>http://pro-tacts/sync/19/336e00f5e1ec2674</d:sync-token>
         <d:current-user-privilege-set>
           <d:privilege>
             <d:read/>
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index 55254af..c7bc124 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
           </d:supported-report>
         </d:supported-report-set>
         <cs:getctag>19</cs:getctag>
-        <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
+        <d:sync-token>http://pro-tacts/sync/19/336e00f5e1ec2674</d:sync-token>
         <d:current-user-privilege-set>
           <d:privilege>
             <d:read/>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index 3dbff83..2185e73 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
           </d:supported-report>
         </d:supported-report-set>
         <cs:getctag>19</cs:getctag>
-        <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
+        <d:sync-token>http://pro-tacts/sync/19/336e00f5e1ec2674</d:sync-token>
         <d:current-user-privilege-set>
           <d:privilege>
             <d:read/>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 55254af..c7bc124 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
           </d:supported-report>
         </d:supported-report-set>
         <cs:getctag>19</cs:getctag>
-        <d:sync-token>http://pro-tacts/sync/19/c8dae6371b33b8d6</d:sync-token>
+        <d:sync-token>http://pro-tacts/sync/19/336e00f5e1ec2674</d:sync-token>
         <d:current-user-privilege-set>
           <d:privilege>
             <d:read/>
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 153da97..1bc5f57 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -21,8 +21,7 @@ class AdminContactsPagesTest < Minitest::Test
   end
 
   def setup
-    header "Tailscale-User-Login", "test@example.com"
-    header "Tailscale-User-Name", "Test User"
+    header "Remote-User", "test@example.com"
   end
 
   ADA = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
diff --git a/test/pro_tacts/admin/test_device_setup.rb b/test/pro_tacts/admin/test_device_setup.rb
index 3e5cef3..3719278 100644
--- a/test/pro_tacts/admin/test_device_setup.rb
+++ b/test/pro_tacts/admin/test_device_setup.rb
@@ -17,8 +17,7 @@ class AdminDeviceSetupTest < Minitest::Test
   end
 
   def setup
-    header "Tailscale-User-Login", "test@example.com"
-    header "Tailscale-User-Name", "Test User"
+    header "Remote-User", "test@example.com"
     header "Host", "box.example.ts.net"
   end
 
@@ -80,7 +79,7 @@ class AdminDeviceSetupTest < Minitest::Test
   end
 
   def test_document_names_the_requester_as_the_username
-    header "Tailscale-User-Login", "zoe@example.com"
+    header "Remote-User", "zoe@example.com"
     get "/setup/carddav.mobileconfig"
 
     assert_match(%r{<key>CardDAVUsername</key>\s*<string>zoe@example.com</string>}, last_response.body)
diff --git a/test/pro_tacts/admin/test_footer.rb b/test/pro_tacts/admin/test_footer.rb
index 11bc16e..c4cc8ef 100644
--- a/test/pro_tacts/admin/test_footer.rb
+++ b/test/pro_tacts/admin/test_footer.rb
@@ -17,8 +17,7 @@ class AdminFooterTest < Minitest::Test
   end
 
   def setup
-    header "Tailscale-User-Login", "test@example.com"
-    header "Tailscale-User-Name", "Test User"
+    header "Remote-User", "test@example.com"
     @config = ProTacts.config
   end
 
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 0ac91f0..b27e94c 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -16,8 +16,7 @@ class AdminGroupsPagesTest < Minitest::Test
   end
 
   def setup
-    header "Tailscale-User-Login", "test@example.com"
-    header "Tailscale-User-Name", "Test User"
+    header "Remote-User", "test@example.com"
   end
 
   ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
diff --git a/test/pro_tacts/exchange_fixtures.rb b/test/pro_tacts/exchange_fixtures.rb
index d66e4cf..e7cc78d 100644
--- a/test/pro_tacts/exchange_fixtures.rb
+++ b/test/pro_tacts/exchange_fixtures.rb
@@ -1,7 +1,7 @@
 require "pathname"
 require "rack/test"
 
-require "pro_tacts/tailscale_auth"
+require "pro_tacts/proxy_auth"
 
 # One recorded client exchange: a directory of numbered steps, replayed
 # in order. See each directory's README for provenance and the
@@ -28,12 +28,11 @@ class ExchangeFixtures < Data.define(:directory)
   # (macos-exchange/README.md).
   REQUEST_HEADERS = %w[Brief Content-Type Depth If-Match If-None-Match Prefer].freeze
 
-  # Stand in for the Tailscale identity headers the recorded sessions
-  # carried. They were stripped from the request files because they name
-  # a real tailnet user; the app refuses requests without them, so the
-  # replay has to put an identity back.
+  # Stands in for the identity header the recorded sessions carried. It
+  # was stripped from the request files because it names a real tailnet
+  # user; the app refuses requests without one, so the replay has to put
+  # a login back.
   REPLAY_LOGIN = "replay@example.com"
-  REPLAY_NAME = "Replay"
 
   # Every recording, for the callers that run all of them.
   def self.all
@@ -77,8 +76,7 @@ class ExchangeFixtures < Data.define(:directory)
       key = name == "Content-Type" ? "CONTENT_TYPE" : "HTTP_#{name.tr('-', '_').upcase}"
       [key, value]
     }.merge(
-      ProTacts::TailscaleAuth::LOGIN_HEADER => REPLAY_LOGIN,
-      ProTacts::TailscaleAuth::NAME_HEADER => REPLAY_NAME,
+      ProTacts::ProxyAuth.env_key(ProTacts.config.identity_header) => REPLAY_LOGIN,
     )
   end
 
diff --git a/test/pro_tacts/test_config.rb b/test/pro_tacts/test_config.rb
index 8dc0a5a..3e41b87 100644
--- a/test/pro_tacts/test_config.rb
+++ b/test/pro_tacts/test_config.rb
@@ -58,6 +58,16 @@ class ConfigTest < Minitest::Test
     assert_equal Pathname.new("stderr"), ProTacts::Config.new("PRO_TACTS_EXCHANGE_LOG" => "stderr").exchange_log_path
   end
 
+  def test_identity_header_defaults_to_remote_user
+    assert_equal "Remote-User", ProTacts::Config.new({}).identity_header
+  end
+
+  def test_identity_header_is_overridable
+    config = ProTacts::Config.new("PRO_TACTS_IDENTITY_HEADER" => "Tailscale-Login")
+
+    assert_equal "Tailscale-Login", config.identity_header
+  end
+
   # nil rather than a default, so the fallback stays Profile's alone.
   def test_profile_name_is_nil_when_unset
     assert_nil ProTacts::Config.new({}).profile_name
diff --git a/test/pro_tacts/test_proxy_auth.rb b/test/pro_tacts/test_proxy_auth.rb
new file mode 100644
index 0000000..814476b
--- /dev/null
+++ b/test/pro_tacts/test_proxy_auth.rb
@@ -0,0 +1,78 @@
+require_relative "../test_helper"
+
+require "pro_tacts/proxy_auth"
+
+# What the header says. That a request naming nobody is refused, and how,
+# is Web's (see WebTest).
+class ProxyAuthTest < Minitest::Test
+  # The header these tests configure the read for, passed explicitly so
+  # nothing here depends on what ProTacts.config defaults to.
+  HEADER = "Remote-User"
+
+  def login(value = "alpha@example.com", sent_as: HEADER)
+    env = {} #: Hash[String, String]
+    env[ProTacts::ProxyAuth.env_key(sent_as)] = value unless value.nil?
+    ProTacts::ProxyAuth.login(env, header: HEADER)
+  end
+
+  def test_the_header_is_the_login
+    assert_equal "alpha@example.com", login
+  end
+
+  def test_no_header_is_nobody
+    assert_nil login(nil)
+  end
+
+  def test_an_empty_login_is_nobody
+    assert_nil login("")
+  end
+
+  def test_a_blank_login_is_nobody
+    assert_nil login("   ")
+  end
+
+  def test_a_login_on_another_header_is_nobody
+    assert_nil login(sent_as: "Tailscale-Login")
+  end
+
+  def test_the_header_read_is_the_one_named
+    env = { ProTacts::ProxyAuth.env_key("Tailscale-Login") => "alpha@example.com" }
+
+    assert_equal "alpha@example.com", ProTacts::ProxyAuth.login(env, header: "Tailscale-Login")
+  end
+
+  def test_a_header_name_is_upcased_and_prefixed
+    assert_equal "HTTP_REMOTE_USER", ProTacts::ProxyAuth.env_key("Remote-User")
+    assert_equal "HTTP_TAILSCALE_USER_LOGIN", ProTacts::ProxyAuth.env_key("tailscale-user-login")
+  end
+
+  # What Go's mime.QEncoding writes for a non-ASCII value, which is how
+  # serve sends one.
+  def test_a_q_encoded_login_is_decoded
+    assert_equal "zoë@example.com", login("=?utf-8?q?zo=C3=AB@example.com?=")
+  end
+
+  def test_whitespace_between_encoded_words_is_dropped
+    assert_equal "Zoë Chen", login("=?utf-8?q?Zo=C3=AB?= =?utf-8?q?_Chen?=")
+  end
+
+  def test_whitespace_around_plain_text_is_kept
+    assert_equal "Dr Zoë Chen", login("Dr =?utf-8?q?Zo=C3=AB?= Chen")
+  end
+
+  def test_a_b_encoded_login_is_decoded
+    assert_equal "Zoë Chen", login("=?UTF-8?B?Wm/DqyBDaGVu?=")
+  end
+
+  def test_a_login_in_another_charset_is_nobody
+    assert_nil login("=?iso-8859-1?q?Zo=EB?=")
+  end
+
+  def test_a_login_that_decodes_to_invalid_utf8_is_nobody
+    assert_nil login("=?utf-8?q?Zo=EB?=")
+  end
+
+  def test_a_malformed_b_word_is_nobody
+    assert_nil login("=?utf-8?b?not*base64?=")
+  end
+end
diff --git a/test/pro_tacts/test_tailscale_auth.rb b/test/pro_tacts/test_tailscale_auth.rb
deleted file mode 100644
index 57a7fa6..0000000
--- a/test/pro_tacts/test_tailscale_auth.rb
+++ /dev/null
@@ -1,72 +0,0 @@
-require_relative "../test_helper"
-
-require "pro_tacts/tailscale_auth"
-
-# What the headers say. That a request naming nobody is refused, and how,
-# is Web's (see WebTest).
-class TailscaleAuthTest < Minitest::Test
-  def identity(login: "alpha@example.com", name: "Alpha Chen")
-    env = {} #: Hash[String, String]
-    env[ProTacts::TailscaleAuth::LOGIN_HEADER] = login unless login.nil?
-    env[ProTacts::TailscaleAuth::NAME_HEADER] = name unless name.nil?
-    ProTacts::TailscaleAuth.identity(env)
-  end
-
-  def test_both_headers_are_the_identity
-    assert_equal ProTacts::TailscaleAuth::Identity.new(login: "alpha@example.com", name: "Alpha Chen"), identity
-  end
-
-  def test_no_headers_are_nobody
-    assert_nil identity(login: nil, name: nil)
-  end
-
-  def test_an_empty_login_is_nobody
-    assert_nil identity(login: "")
-  end
-
-  def test_a_blank_login_is_nobody
-    assert_nil identity(login: "   ")
-  end
-
-  def test_a_missing_name_is_nobody
-    assert_nil identity(name: nil)
-  end
-
-  def test_a_blank_name_is_nobody
-    assert_nil identity(name: "   ")
-  end
-
-  # What Go's mime.QEncoding writes for a non-ASCII value, which is how
-  # serve sends one.
-  def test_a_q_encoded_name_is_decoded
-    assert_equal "Zoë Chen", identity(name: "=?utf-8?q?Zo=C3=AB_Chen?=").name
-  end
-
-  def test_a_q_encoded_login_is_decoded
-    assert_equal "zoë@example.com", identity(login: "=?utf-8?q?zo=C3=AB@example.com?=").login
-  end
-
-  def test_whitespace_between_encoded_words_is_dropped
-    assert_equal "Zoë Chen", identity(name: "=?utf-8?q?Zo=C3=AB?= =?utf-8?q?_Chen?=").name
-  end
-
-  def test_whitespace_around_plain_text_is_kept
-    assert_equal "Dr Zoë Chen", identity(name: "Dr =?utf-8?q?Zo=C3=AB?= Chen").name
-  end
-
-  def test_a_b_encoded_name_is_decoded
-    assert_equal "Zoë Chen", identity(name: "=?UTF-8?B?Wm/DqyBDaGVu?=").name
-  end
-
-  def test_a_name_in_another_charset_is_nobody
-    assert_nil identity(name: "=?iso-8859-1?q?Zo=EB?=")
-  end
-
-  def test_a_name_that_decodes_to_invalid_utf8_is_nobody
-    assert_nil identity(name: "=?utf-8?q?Zo=EB?=")
-  end
-
-  def test_a_malformed_b_word_is_nobody
-    assert_nil identity(name: "=?utf-8?b?not*base64?=")
-  end
-end
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 494b913..94aa899 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -21,13 +21,13 @@ class WebTest < Minitest::Test
     ProTacts::Web
   end
 
-  # Every request needs a Tailscale identity; the route refuses without
-  # one (see the refusal tests below). The sentry setup
+  # Every request names a tailnet user; the route refuses without one
+  # (see the refusal tests below). The header is the configured default
+  # (ProTacts::Config#identity_header). The sentry setup
   # pins the transport the app's reports land in; teardown clears it.
   def setup
     setup_sentry
-    header "Tailscale-User-Login", "test@example.com"
-    header "Tailscale-User-Name", "Test User"
+    header "Remote-User", "test@example.com"
   end
 
   def teardown
@@ -165,8 +165,8 @@ class WebTest < Minitest::Test
 
   ## Refusing a request that names nobody
 
-  def test_a_request_without_a_name_is_refused
-    header "Tailscale-User-Name", ""
+  def test_a_request_without_a_login_is_refused
+    header "Remote-User", ""
 
     get "/dav/principal/"
 
@@ -175,7 +175,7 @@ class WebTest < Minitest::Test
 
   # RFC 9110 section 15.5.2: a 401 carries a challenge.
   def test_a_refusal_names_tailscale_as_the_credential
-    header "Tailscale-User-Login", ""
+    header "Remote-User", ""
 
     get "/"
 
@@ -187,7 +187,7 @@ class WebTest < Minitest::Test
   # Every route is gated, not just the address book: an unauthenticated
   # request must not learn whether a path exists.
   def test_a_refusal_covers_every_path
-    header "Tailscale-User-Login", ""
+    header "Remote-User", ""
 
     %w[/ /.well-known/carddav /dav/ /dav/principal/ /dav/addressbook/ /contacts /groups /setup /admin.css].each do |path|
       get path
@@ -785,8 +785,8 @@ class WebTest < Minitest::Test
   end
 
   # A sync token as the route issues one to a user (Web#sync_token).
-  def issued_token(sequence, name: "Test User")
-    "http://pro-tacts/sync/#{sequence}/#{Digest::SHA256.hexdigest(name)[0, 16]}"
+  def issued_token(sequence, login: "test@example.com")
+    "http://pro-tacts/sync/#{sequence}/#{Digest::SHA256.hexdigest(login)[0, 16]}"
   end
 
   def etag_only_propfind
@@ -953,12 +953,12 @@ class WebTest < Minitest::Test
     end
   end
 
-  # A token names the book it was issued for, so a user whose name
+  # A token names the book it was issued for, so a user whose login
   # changed resyncs rather than taking a delta against another book —
   # and a token from before tokens named one is refused the same way.
   def test_sync_collection_refuses_a_token_from_another_book
     with_contacts({"aiden" => "Aiden"}) do
-      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(1, name: "Zoë Chen"))
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(1, login: "zoë@example.com"))
       assert_equal 410, last_response.status
 
       request "/dav/addressbook/", method: "REPORT", input: sync_collection("http://pro-tacts/sync/1")
@@ -1000,12 +1000,12 @@ class WebTest < Minitest::Test
   def test_each_user_syncs_their_own_book
     with_contacts({"aiden" => "Aiden"}) do |store|
       store.put("znorth", ProTacts::VCard.new(card("znorth", "Zed")))
-      FixtureData.seed_group(store, name: "sync:Zoë Chen", members: ["znorth"])
+      FixtureData.seed_group(store, name: "sync:zoë@example.com", members: ["znorth"])
 
       request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
       refute_includes last_response.body, "znorth"
 
-      header "Tailscale-User-Name", "=?utf-8?q?Zo=C3=AB_Chen?="
+      header "Remote-User", "=?utf-8?q?zo=C3=AB@example.com?="
       request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
       assert_includes last_response.body, "aiden.vcf"
       assert_includes last_response.body, "znorth.vcf"
@@ -1019,8 +1019,8 @@ class WebTest < Minitest::Test
       put_request "new", card("new", "New"), "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
 
       assert_equal 201, last_response.status
-      assert_equal Set["new"], store.book("Test User")
-      assert_equal Set["new"], store.book("Zoë Chen")
+      assert_equal Set["new"], store.book("test@example.com")
+      assert_equal Set["new"], store.book("zoë@example.com")
     end
   end
 
diff --git a/test/tasks/test_fixtures.rb b/test/tasks/test_fixtures.rb
index edb11c9..e7992d3 100644
--- a/test/tasks/test_fixtures.rb
+++ b/test/tasks/test_fixtures.rb
@@ -29,7 +29,7 @@ class FixturesTasksTest < Minitest::Test
   def test_the_request_is_written_as_a_step_without_what_identifies_the_tailnet
     put "/dav/addressbook/new.vcf", CARD,
       "CONTENT_TYPE" => "text/vcard", "HTTP_IF_MATCH" => '"abc"', "HTTP_USER_AGENT" => "AddressBookCore",
-      "HTTP_TAILSCALE_USER_LOGIN" => "ada@example.com", "HTTP_TAILSCALE_USER_NAME" => "Ada"
+      "HTTP_REMOTE_USER" => "ada@example.com"
 
     extract(exchange, @root / "07-put")