users: add anyone Tailscale lets in, keyed by login
Users are no longer configured: the first request from a login adds it, and X-User-Slug from caddy-tailscale names it. This edits 0001_init.sql rather than migrating, so a local docket.db from before has to be deleted.

Assisted-by: Claude Opus 5.5 via Claude Code
change vlxvzsmuxkzqrnwvuopqyzqxxrvvulzo
commit b342e17dc967506b2603d084de4c583d5e677ae9
author Alpha Chen <alpha@kejadlen.dev>
date
parent kltnruut
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index b55387a..d3d555c 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -19,8 +19,9 @@ A small self-hosted webapp for two people to jointly manage household email acco
 ## Users and access
 
 - Two app users.
-- Access is via Tailscale. The app binds to localhost behind `tailscale serve` and identifies users from the `Tailscale-User-Login` header. Requests without a user identity are rejected.
-- Each user has a display name and a short slug.
+- Access is via Tailscale, which is the whole of authentication: anyone the tailnet lets reach Docket is a user, added on their first request. Docket keeps no list of people.
+- The app binds to localhost behind Caddy with [caddy-tailscale](https://github.com/tailscale/caddy-tailscale), which sets two headers from the tailnet identity: `Tailscale-User-Login` (the full login, `{http.auth.user.id}`) identifies the user, and `X-User-Slug` carries a short name the Caddyfile maps from the login. Requests without a login are rejected.
+- A user's slug is what Docket shows for them. It follows the latest `X-User-Slug`, falls back to the part of the login before the `@`, and need not be unique.
 
 ## Credentials and accounts
 
diff --git a/fly.kdl b/fly.kdl
index 429e2b8..090bcc5 100644
--- a/fly.kdl
+++ b/fly.kdl
@@ -2,8 +2,8 @@
 // /etc/docket.kdl (fly.toml points DOCKET_CONFIG at it). Binds wide
 // because Fly's edge routes to the machine, not to localhost. `dev` is
 // the whole of what makes them demos: fixture data instead of JMAP, and
-// a stand-in identity in place of the Tailscale-User-Login header that
-// tailscale serve supplies in front of the real deployment. The database
+// a stand-in identity in place of the identity headers that Caddy
+// (caddy-tailscale) supplies in front of the real deployment. The database
 // sits on the machine's own disk, which a deploy replaces, so each deploy
 // starts from the fixtures again.
 bind "0.0.0.0:3000"
diff --git a/fly.toml b/fly.toml
index 7c67ca8..ded0730 100644
--- a/fly.toml
+++ b/fly.toml
@@ -3,8 +3,8 @@
 # review-app action in .github/workflows/preview.yml, which overrides
 # the app name with the PR's). Both read the config baked into the image
 # from fly.kdl: fixture data instead of JMAP, and a stand-in identity in
-# place of the Tailscale-User-Login header that tailscale serve supplies
-# in front of the real deployment. No volume, so every deploy starts
+# place of the identity headers that Caddy (caddy-tailscale) supplies in
+# front of the real deployment. No volume, so every deploy starts
 # from the fixtures again.
 #
 # The app name is Fly's, not GitHub's, so it has to be unique across all
diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql
index eb3adac..ac7c1e5 100644
--- a/migrations/0001_init.sql
+++ b/migrations/0001_init.sql
@@ -2,11 +2,12 @@
 -- stays the source of truth for messages and folders; JMAP import fills
 -- these tables and fixtures seed them in dev.
 
+-- Everyone Tailscale lets in, added on their first request.
 CREATE TABLE users (
-    slug TEXT PRIMARY KEY,
-    name TEXT NOT NULL,
     -- The Tailscale-User-Login value that identifies this user.
-    login TEXT NOT NULL UNIQUE
+    login TEXT PRIMARY KEY,
+    -- The short name shown for them, from X-User-Slug; not unique.
+    slug TEXT NOT NULL
 ) STRICT;
 
 CREATE TABLE accounts (
@@ -49,7 +50,7 @@ CREATE TABLE messages (
     from_addr TEXT,
     state TEXT CHECK (state IN ('inbox', 'do', 'wait', 'watch', 'done')),
     folder TEXT REFERENCES folders (name),
-    sent_by TEXT REFERENCES users (slug),
+    sent_by TEXT REFERENCES users (login),
     sent_to TEXT,
     UNIQUE (account, message_id),
     CHECK (
@@ -67,13 +68,13 @@ CREATE INDEX messages_state ON messages (state);
 
 CREATE TABLE assignees (
     message INTEGER NOT NULL REFERENCES messages (id),
-    user TEXT NOT NULL REFERENCES users (slug),
+    user TEXT NOT NULL REFERENCES users (login),
     PRIMARY KEY (message, user)
 ) STRICT;
 
 -- Per-person read tracking; $seen is shared and can't say who read what.
 CREATE TABLE reads (
-    user TEXT NOT NULL REFERENCES users (slug),
+    user TEXT NOT NULL REFERENCES users (login),
     message INTEGER NOT NULL REFERENCES messages (id),
     PRIMARY KEY (user, message)
 ) STRICT;
@@ -81,7 +82,7 @@ CREATE TABLE reads (
 CREATE TABLE comments (
     id INTEGER PRIMARY KEY,
     thread INTEGER NOT NULL REFERENCES threads (id),
-    author TEXT NOT NULL REFERENCES users (slug),
+    author TEXT NOT NULL REFERENCES users (login),
     at TEXT NOT NULL,
     text TEXT NOT NULL
 ) STRICT;
@@ -92,7 +93,7 @@ CREATE INDEX comments_thread ON comments (thread);
 CREATE TABLE history (
     id INTEGER PRIMARY KEY,
     message INTEGER NOT NULL REFERENCES messages (id),
-    user TEXT NOT NULL REFERENCES users (slug),
+    user TEXT NOT NULL REFERENCES users (login),
     at TEXT NOT NULL,
     event TEXT NOT NULL
 ) STRICT;
diff --git a/src/bin/docket/config.rs b/src/bin/docket/config.rs
index efe1aab..9934def 100644
--- a/src/bin/docket/config.rs
+++ b/src/bin/docket/config.rs
@@ -18,8 +18,8 @@ const DEFAULT_DATABASE: &str = "docket.db";
 
 #[derive(Debug)]
 pub struct Config {
-    /// Address to listen on. Keep it on localhost; `tailscale serve`
-    /// fronts it and supplies the user identity header.
+    /// Address to listen on. Keep it on localhost; Caddy (caddy-tailscale)
+    /// fronts it and supplies the user identity headers.
     pub bind: SocketAddr,
 
     /// Serve without Tailscale: requests with no identity header act as a
diff --git a/src/fixtures.rs b/src/fixtures.rs
index 3deac30..c0a2170 100644
--- a/src/fixtures.rs
+++ b/src/fixtures.rs
@@ -13,6 +13,9 @@ use crate::store::Store;
 pub const WATER: MessageId = 7;
 pub const ELI_PRACTICE: MessageId = 20;
 
+pub const ALEX: &str = "alex@example.com";
+pub const SAM: &str = "sam@example.com";
+
 /// Thursday, 1 October 2026, at noon.
 pub fn now() -> DateTime {
     at(10, 1, 12, 0)
@@ -127,18 +130,7 @@ pub fn store() -> Result<Store, Error> {
 
 /// Writes the fixtures into an empty database.
 pub fn seed(store: &Store) -> Result<(), Error> {
-    let users = [
-        User {
-            slug: "alex".into(),
-            name: "Alex".into(),
-            login: "alex@example.com".into(),
-        },
-        User {
-            slug: "sam".into(),
-            name: "Sam".into(),
-            login: "sam@example.com".into(),
-        },
-    ];
+    let users = [User::new(ALEX, Some("Alex")), User::new(SAM, Some("Sam"))];
     let accounts = [
         Account {
             slug: "household".into(),
@@ -172,7 +164,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
     );
     b.comment(
         1,
-        "alex",
+        ALEX,
         at(9, 20, 19, 30),
         "The Hendersons paid about $1,600 for theirs.",
     );
@@ -180,7 +172,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
         2,
         1,
         at(9, 21, 8, 40),
-        ("sam", &["Northwind Roofing"]),
+        (SAM, &["Northwind Roofing"]),
         "Does this include the downspout on the north side?",
         (&["Alex"], &["Pat Lee"]),
     );
@@ -210,7 +202,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
         at(9, 30, 15, 30),
         school,
         "Please return the signed permission form by Friday.",
-        (Do, Some("School"), &["sam"]),
+        (Do, Some("School"), &[SAM]),
         &[],
     );
     b.recv(
@@ -219,10 +211,10 @@ pub fn seed(store: &Store) -> Result<(), Error> {
         at(10, 1, 9, 12),
         school,
         "A schedule update for the museum trip: buses now return at 4:15, so pickup moves to 4:30 PM at the front entrance.",
-        (Inbox, Some("School"), &["alex"]),
+        (Inbox, Some("School"), &[ALEX]),
         &[],
     );
-    b.comment(2, "sam", at(10, 1, 9, 20), "You have pickup that day.");
+    b.comment(2, SAM, at(10, 1, 9, 20), "You have pickup that day.");
 
     b.thread(3, "household", "Service interruption Oct 4");
     b.recv(
@@ -264,10 +256,10 @@ pub fn seed(store: &Store) -> Result<(), Error> {
         at(10, 1, 8, 15),
         ("Dr. Patel’s Office", "frontdesk@patelpeds.com"),
         "This confirms Eli’s appointment on Thursday, October 8 at 3:00 PM. Please arrive ten minutes early.",
-        (Inbox, Some("Medical"), &["alex"]),
+        (Inbox, Some("Medical"), &[ALEX]),
         &[],
     );
-    b.comment(6, "sam", at(10, 1, 9, 41), "Thursday at 3, I’ll take him.");
+    b.comment(6, SAM, at(10, 1, 9, 41), "Thursday at 3, I’ll take him.");
 
     b.thread(7, "household", "Claim 4471: adjuster visit");
     b.recv(
@@ -276,14 +268,14 @@ pub fn seed(store: &Store) -> Result<(), Error> {
         at(9, 23, 13, 0),
         ("State Farm", "claims@statefarm.com"),
         "Claim 4471 is open. An adjuster will contact you within five business days to schedule a visit.",
-        (Wait, Some("House"), &["alex"]),
+        (Wait, Some("House"), &[ALEX]),
         &[],
     );
     b.sent(
         12,
         7,
         at(9, 24, 9, 30),
-        ("alex", &["State Farm"]),
+        (ALEX, &["State Farm"]),
         "Thanks. Mornings work best for us, any day next week.",
         (&[], &[]),
     );
@@ -328,7 +320,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
         at(9, 29, 17, 0),
         ("Coach Rivera", "rivera@lincolnhigh.org"),
         "Practice moves to Thursday this week, 4 to 6 PM. Bring water.",
-        (Watch, None, &["sam"]),
+        (Watch, None, &[SAM]),
         &[],
     );
 
@@ -338,9 +330,9 @@ pub fn seed(store: &Store) -> Result<(), Error> {
         .messages
         .iter()
         .filter(|m| m.at < cutoff)
-        .flat_map(|m| [("alex", m.id), ("sam", m.id)])
+        .flat_map(|m| [(ALEX, m.id), (SAM, m.id)])
         .collect();
-    reads.push(("sam", 5));
+    reads.push((SAM, 5));
 
     store.import(|tx| {
         for user in &users {
diff --git a/src/lists.rs b/src/lists.rs
index 66cbb92..791080c 100644
--- a/src/lists.rs
+++ b/src/lists.rs
@@ -49,7 +49,7 @@ pub fn sections(store: &Store, me: &User, view: &View) -> Result<Vec<Section>, E
         View::ForMe => {
             for state in State::LANES {
                 let msgs = store.messages(Filter::ForMe {
-                    user: &me.slug,
+                    user: &me.login,
                     state,
                 })?;
                 let head = state.name().to_uppercase();
@@ -124,7 +124,7 @@ fn section(
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::fixtures;
+    use crate::fixtures::{self, ALEX, SAM};
 
     fn summary(store: &Store, user: &str, view: &View) -> Vec<(String, Vec<Vec<u32>>)> {
         let me = store.user(user).unwrap().unwrap();
@@ -153,16 +153,16 @@ mod tests {
     #[test]
     fn for_me_groups_by_state_then_thread() {
         let store = fixtures::store().unwrap();
-        let sam = summary(&store, "sam", &View::ForMe);
-        assert_eq!(heads(&store, "sam", &View::ForMe), ["INBOX", "DO", "WATCH"]);
+        let sam = summary(&store, SAM, &View::ForMe);
+        assert_eq!(heads(&store, SAM, &View::ForMe), ["INBOX", "DO", "WATCH"]);
         let (_, groups) = &sam[0];
         // Newest thread first; the roofing thread shows only its two Inbox emails.
         assert_eq!(groups[0], vec![fixtures::WATER]);
         assert!(groups.contains(&vec![3, 4]));
 
         // Alex's own Inbox messages share the section with unassigned ones.
-        let alex = summary(&store, "alex", &View::ForMe);
-        assert_eq!(heads(&store, "alex", &View::ForMe), ["INBOX", "WAIT"]);
+        let alex = summary(&store, ALEX, &View::ForMe);
+        assert_eq!(heads(&store, ALEX, &View::ForMe), ["INBOX", "WAIT"]);
         assert_eq!(
             alex[0].1,
             [vec![7], vec![6], vec![10], vec![8], vec![9], vec![3, 4]]
@@ -172,7 +172,7 @@ mod tests {
     #[test]
     fn sidebar_count() {
         let store = fixtures::store().unwrap();
-        let sam = store.user("sam").unwrap().unwrap();
+        let sam = store.user(SAM).unwrap().unwrap();
         assert_eq!(count(&store, &sam, &View::ForMe).unwrap(), 7);
     }
 
@@ -181,16 +181,16 @@ mod tests {
         let store = fixtures::store().unwrap();
         // Every assignee shares one unheaded section: Inbox newest first, Do
         // oldest first.
-        let inbox = summary(&store, "sam", &View::Lane(State::Inbox));
-        assert_eq!(heads(&store, "sam", &View::Lane(State::Inbox)), [""]);
+        let inbox = summary(&store, SAM, &View::Lane(State::Inbox));
+        assert_eq!(heads(&store, SAM, &View::Lane(State::Inbox)), [""]);
         assert_eq!(inbox[0].1[0], vec![fixtures::WATER]);
-        let todo = summary(&store, "alex", &View::Lane(State::Do));
-        assert_eq!(heads(&store, "alex", &View::Lane(State::Do)), [""]);
+        let todo = summary(&store, ALEX, &View::Lane(State::Do));
+        assert_eq!(heads(&store, ALEX, &View::Lane(State::Do)), [""]);
         assert_eq!(todo[0].1, [vec![13], vec![5]]);
-        let wait = summary(&store, "sam", &View::Lane(State::Wait));
+        let wait = summary(&store, SAM, &View::Lane(State::Wait));
         assert_eq!(wait[0].1, vec![vec![11]]);
 
-        let me = store.user("sam").unwrap().unwrap();
+        let me = store.user(SAM).unwrap().unwrap();
         let watch = sections(&store, &me, &View::Lane(State::Watch)).unwrap();
         assert!(watch[0].compact);
         assert_eq!(watch[0].groups[0].rows[0].id, 14);
@@ -205,14 +205,14 @@ mod tests {
     #[test]
     fn search_covers_every_message() {
         let store = fixtures::store().unwrap();
-        let hits = summary(&store, "sam", &View::Search("downspout".into()));
+        let hits = summary(&store, SAM, &View::Search("downspout".into()));
         assert_eq!(hits[0].1, vec![vec![2, 4]]);
-        let by_sender = summary(&store, "sam", &View::Search("ALEX".into()));
+        let by_sender = summary(&store, SAM, &View::Search("ALEX".into()));
         assert_eq!(by_sender[0].1, vec![vec![12]]);
-        let by_subject = summary(&store, "sam", &View::Search("checkup".into()));
+        let by_subject = summary(&store, SAM, &View::Search("checkup".into()));
         assert_eq!(by_subject[0].1, vec![vec![15]]);
-        assert!(summary(&store, "sam", &View::Search("  ".into())).is_empty());
-        assert!(summary(&store, "sam", &View::Search("zzz".into())).is_empty());
+        assert!(summary(&store, SAM, &View::Search("  ".into())).is_empty());
+        assert!(summary(&store, SAM, &View::Search("zzz".into())).is_empty());
     }
 
     #[test]
diff --git a/src/model.rs b/src/model.rs
index 37bc54e..603aeaf 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -63,12 +63,29 @@ impl fmt::Display for State {
     }
 }
 
-#[derive(Debug, Clone)]
+/// Someone Tailscale let in. Tailscale is the whole of authentication, so
+/// anyone who reaches Docket is a user.
+#[derive(Debug, Clone, PartialEq, Eq)]
 pub struct User {
-    pub slug: String,
-    pub name: String,
     /// The `Tailscale-User-Login` value that identifies this user.
     pub login: String,
+    /// The short name shown for them. Not unique: two logins can share one.
+    pub slug: String,
+}
+
+impl User {
+    /// The slug the proxy sent, or the part of the login before the `@`.
+    pub fn new(login: &str, slug: Option<&str>) -> Self {
+        let fallback = login.split_once('@').map_or(login, |(name, _)| name);
+        let slug = slug
+            .map(str::trim)
+            .filter(|s| !s.is_empty())
+            .unwrap_or(fallback);
+        Self {
+            login: login.to_owned(),
+            slug: slug.to_owned(),
+        }
+    }
 }
 
 #[derive(Debug, Clone)]
@@ -91,7 +108,7 @@ pub struct Thread {
 pub struct Values {
     pub state: State,
     pub folder: Option<String>,
-    /// User slugs.
+    /// User logins.
     pub assignees: BTreeSet<String>,
 }
 
@@ -102,7 +119,7 @@ pub enum Kind {
         addr: String,
         values: Values,
     },
-    /// Sent by one of us. Sent messages carry no values.
+    /// Sent by one of us (`by` is a login). Sent messages carry no values.
     Sent { by: String, to: Vec<String> },
 }
 
@@ -133,6 +150,7 @@ impl Message {
 pub struct Comment {
     pub id: CommentId,
     pub thread: ThreadId,
+    /// A login.
     pub author: String,
     pub at: DateTime,
     pub text: String,
@@ -142,6 +160,7 @@ pub struct Comment {
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct Event {
     pub message: MessageId,
+    /// A login.
     pub user: String,
     pub at: DateTime,
     pub text: String,
@@ -151,6 +170,16 @@ pub struct Event {
 mod tests {
     use super::*;
 
+    #[test]
+    fn slugs_fall_back_to_the_login() {
+        let named = User::new("alex@example.com", Some(" Al "));
+        assert_eq!(named.login, "alex@example.com");
+        assert_eq!(named.slug, "Al");
+        assert_eq!(User::new("alex@example.com", None).slug, "alex");
+        assert_eq!(User::new("alex@example.com", Some("  ")).slug, "alex");
+        assert_eq!(User::new("tagged-device", None).slug, "tagged-device");
+    }
+
     #[test]
     fn state_slugs_round_trip() {
         for state in State::ALL {
diff --git a/src/routes.rs b/src/routes.rs
index 4a8e212..aa41338 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -17,7 +17,10 @@ use crate::model::{MessageId, State, ThreadId, User};
 use crate::store::{Change, Store};
 use crate::views::{self, Page, ThreadView};
 
+// caddy-tailscale sets both from the tailnet identity, overwriting whatever
+// the client sent.
 const IDENTITY_HEADER: &str = "Tailscale-User-Login";
+const SLUG_HEADER: &str = "X-User-Slug";
 const DEV_COOKIE: &str = "docket_dev_user";
 
 #[derive(Clone)]
@@ -60,7 +63,8 @@ async fn health() -> &'static str {
     "ok"
 }
 
-/// The requesting user, from Tailscale's identity header.
+/// The requesting user, from the identity headers caddy-tailscale adds. Anyone
+/// Tailscale lets through is a user; the first request adds them.
 struct Me(User);
 
 impl FromRequestParts<AppState> for Me {
@@ -73,17 +77,15 @@ impl FromRequestParts<AppState> for Me {
             return Err(Error::Forbidden("cross-site request"));
         }
         let store = &state.store;
-        if let Some(login) = header(&parts.headers, IDENTITY_HEADER) {
-            return store
-                .user_by_login(login)?
-                .map(Me)
-                .ok_or(Error::Unauthenticated);
+        if let Some(login) = header(&parts.headers, IDENTITY_HEADER).filter(|l| !l.is_empty()) {
+            let slug = header(&parts.headers, SLUG_HEADER);
+            return Ok(Me(store.sign_in(login, slug)?));
         }
         if !state.dev {
             return Err(Error::Unauthenticated);
         }
         let chosen = match cookie(&parts.headers, DEV_COOKIE) {
-            Some(slug) => store.user(slug)?,
+            Some(login) => store.user(login)?,
             None => None,
         };
         let user = match chosen {
@@ -173,10 +175,10 @@ fn render(
         folders: store.folders()?,
         nav,
         sections: lists::sections(store, me, view)?,
-        unread: store.unread(&me.slug)?,
+        unread: store.unread(&me.login)?,
         selected,
         thread,
-        flash: store.take_flash(&me.slug),
+        flash: store.take_flash(&me.login),
         here,
         dev: state.dev,
     }))
@@ -190,8 +192,8 @@ fn open_thread(store: &Store, me: &User, id: MessageId) -> Result<ThreadView, Er
         .thread_messages(msg.thread)?
         .last()
         .map_or(id, |m| m.id);
-    store.mark_read(&me.slug, id)?;
-    store.mark_read(&me.slug, latest)?;
+    store.mark_read(&me.login, id)?;
+    store.mark_read(&me.login, latest)?;
     Ok(ThreadView {
         thread: store.thread(msg.thread)?.ok_or(Error::NotFound("thread"))?,
         read_only: store
@@ -272,7 +274,7 @@ async fn edit(
     change: Change,
     back: &str,
 ) -> Result<Redirect, Error> {
-    state.store.edit(&me.slug, id, change)?;
+    state.store.edit(&me.login, id, change)?;
     Ok(Redirect::to(safe_back(back)))
 }
 
@@ -288,7 +290,7 @@ async fn add_comment(
     Path(thread): Path<ThreadId>,
     Form(form): Form<CommentForm>,
 ) -> Result<Redirect, Error> {
-    state.store.add_comment(&me.slug, thread, &form.text)?;
+    state.store.add_comment(&me.login, thread, &form.text)?;
     Ok(Redirect::to(safe_back(&form.back)))
 }
 
@@ -302,7 +304,7 @@ async fn undo(
     Me(me): Me,
     Form(form): Form<BackForm>,
 ) -> Result<Redirect, Error> {
-    state.store.undo(&me.slug)?;
+    state.store.undo(&me.login)?;
     Ok(Redirect::to(safe_back(&form.back)))
 }
 
diff --git a/src/store.rs b/src/store.rs
index 2a9412f..7e14034 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -122,27 +122,26 @@ impl Store {
         let inner = self.lock();
         let mut stmt = inner
             .conn
-            .prepare_cached("SELECT slug, name, login FROM users ORDER BY rowid")?;
+            .prepare_cached("SELECT login, slug FROM users ORDER BY rowid")?;
         let users = stmt
             .query_map([], user_from_row)?
             .collect::<Result<_, _>>()?;
         Ok(users)
     }
 
-    pub fn user(&self, slug: &str) -> Result<Option<User>, Error> {
-        load_user(&self.lock().conn, slug)
+    pub fn user(&self, login: &str) -> Result<Option<User>, Error> {
+        load_user(&self.lock().conn, login)
     }
 
-    pub fn user_by_login(&self, login: &str) -> Result<Option<User>, Error> {
-        let user = self
-            .lock()
-            .conn
-            .query_row(
-                "SELECT slug, name, login FROM users WHERE login = ?1",
-                [login],
-                user_from_row,
-            )
-            .optional()?;
+    /// The user behind a request Tailscale identified, added on their first
+    /// request. Their slug follows whatever the proxy sends.
+    pub fn sign_in(&self, login: &str, slug: Option<&str>) -> Result<User, Error> {
+        let user = User::new(login, slug);
+        self.lock().conn.execute(
+            "INSERT INTO users (login, slug) VALUES (?1, ?2)
+             ON CONFLICT (login) DO UPDATE SET slug = excluded.slug WHERE slug != excluded.slug",
+            params![user.login, user.slug],
+        )?;
         Ok(user)
     }
 
@@ -210,7 +209,7 @@ impl Store {
                 load_messages(
                     conn,
                     r"t.subject LIKE ?1 ESCAPE '\' OR m.from_name LIKE ?1 ESCAPE '\'
-                        OR u.name LIKE ?1 ESCAPE '\' OR m.body LIKE ?1 ESCAPE '\'",
+                        OR u.slug LIKE ?1 ESCAPE '\' OR m.body LIKE ?1 ESCAPE '\'",
                     [pattern],
                 )
             }
@@ -319,12 +318,12 @@ impl Store {
                     }
                 }
             }
-            Change::ToggleAssignee(slug) => {
-                let name = load_user(&tx, &slug)?.ok_or(Error::NotFound("user"))?.name;
-                if next.assignees.remove(&slug) {
+            Change::ToggleAssignee(login) => {
+                let name = load_user(&tx, &login)?.ok_or(Error::NotFound("user"))?.slug;
+                if next.assignees.remove(&login) {
                     format!("Unassigned {name}")
                 } else {
-                    next.assignees.insert(slug);
+                    next.assignees.insert(login);
                     format!("Assigned {name}")
                 }
             }
@@ -403,8 +402,8 @@ pub struct Import<'a> {
 impl Import<'_> {
     pub fn user(&self, user: &User) -> Result<(), Error> {
         self.tx.execute(
-            "INSERT INTO users (slug, name, login) VALUES (?1, ?2, ?3)",
-            params![user.slug, user.name, user.login],
+            "INSERT INTO users (login, slug) VALUES (?1, ?2)",
+            params![user.login, user.slug],
         )?;
         Ok(())
     }
@@ -519,17 +518,16 @@ fn migrate(conn: &mut Connection) -> Result<(), Error> {
 
 fn user_from_row(r: &Row<'_>) -> rusqlite::Result<User> {
     Ok(User {
-        slug: r.get(0)?,
-        name: r.get(1)?,
-        login: r.get(2)?,
+        login: r.get(0)?,
+        slug: r.get(1)?,
     })
 }
 
-fn load_user(conn: &Connection, slug: &str) -> Result<Option<User>, Error> {
+fn load_user(conn: &Connection, login: &str) -> Result<Option<User>, Error> {
     let user = conn
         .query_row(
-            "SELECT slug, name, login FROM users WHERE slug = ?1",
-            [slug],
+            "SELECT login, slug FROM users WHERE login = ?1",
+            [login],
             user_from_row,
         )
         .optional()?;
@@ -582,7 +580,7 @@ fn load_messages(
                 FROM (SELECT user FROM assignees WHERE message = m.id ORDER BY user))
          FROM messages m
          JOIN threads t ON t.id = m.thread
-         LEFT JOIN users u ON u.slug = m.sent_by
+         LEFT JOIN users u ON u.login = m.sent_by
          WHERE {filter}
          ORDER BY m.at, m.id"
     );
@@ -701,7 +699,7 @@ fn escape_like(text: &str) -> String {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::fixtures;
+    use crate::fixtures::{self, ALEX, SAM};
 
     fn values(store: &Store, id: MessageId) -> Values {
         store
@@ -716,21 +714,21 @@ mod tests {
     #[test]
     fn edits_record_undo_flash_and_history() {
         let store = fixtures::store().unwrap();
-        store.edit("sam", 4, Change::State(State::Do)).unwrap();
+        store.edit(SAM, 4, Change::State(State::Do)).unwrap();
         assert_eq!(values(&store, 4).state, State::Do);
         assert_eq!(
-            store.take_flash("sam"),
+            store.take_flash(SAM),
             Some(Flash {
                 text: "Moved to Do".into(),
                 undoable: true
             })
         );
-        assert_eq!(store.take_flash("sam"), None);
+        assert_eq!(store.take_flash(SAM), None);
 
-        store.undo("sam").unwrap();
+        store.undo(SAM).unwrap();
         assert_eq!(values(&store, 4).state, State::Inbox);
-        assert_eq!(store.take_flash("sam").unwrap().text, "Undone");
-        assert!(matches!(store.undo("sam"), Err(Error::BadRequest(_))));
+        assert_eq!(store.take_flash(SAM).unwrap().text, "Undone");
+        assert!(matches!(store.undo(SAM), Err(Error::BadRequest(_))));
 
         let history: Vec<_> = store
             .history(4)
@@ -741,8 +739,8 @@ mod tests {
         assert_eq!(
             history,
             [
-                ("sam".into(), store.now, "Moved to Do".into()),
-                ("sam".into(), store.now, "Undone".into()),
+                (SAM.into(), store.now, "Moved to Do".into()),
+                (SAM.into(), store.now, "Undone".into()),
             ]
         );
     }
@@ -751,14 +749,14 @@ mod tests {
     fn folders() {
         let store = fixtures::store().unwrap();
         store
-            .edit("sam", 4, Change::Folder(Some("Finance".into())))
+            .edit(SAM, 4, Change::Folder(Some("Finance".into())))
             .unwrap();
         assert_eq!(values(&store, 4).folder.as_deref(), Some("Finance"));
-        store.edit("sam", 4, Change::Folder(None)).unwrap();
+        store.edit(SAM, 4, Change::Folder(None)).unwrap();
         assert_eq!(values(&store, 4).folder, None);
-        assert_eq!(store.take_flash("sam").unwrap().text, "Removed from folder");
+        assert_eq!(store.take_flash(SAM).unwrap().text, "Removed from folder");
         assert!(matches!(
-            store.edit("sam", 4, Change::Folder(Some("Nope".into()))),
+            store.edit(SAM, 4, Change::Folder(Some("Nope".into()))),
             Err(Error::NotFound("folder"))
         ));
         assert_eq!(
@@ -772,27 +770,27 @@ mod tests {
         let store = fixtures::store().unwrap();
         let eli = fixtures::ELI_PRACTICE;
         assert!(matches!(
-            store.edit("sam", eli, Change::Folder(None)),
+            store.edit(SAM, eli, Change::Folder(None)),
             Err(Error::Forbidden(_))
         ));
-        store.edit("sam", eli, Change::State(State::Done)).unwrap();
+        store.edit(SAM, eli, Change::State(State::Done)).unwrap();
     }
 
     #[test]
     fn assignees_toggle() {
         let store = fixtures::store().unwrap();
         store
-            .edit("alex", 4, Change::ToggleAssignee("alex".into()))
+            .edit(ALEX, 4, Change::ToggleAssignee(ALEX.into()))
             .unwrap();
-        assert!(values(&store, 4).assignees.contains("alex"));
-        assert_eq!(store.take_flash("alex").unwrap().text, "Assigned Alex");
+        assert!(values(&store, 4).assignees.contains(ALEX));
+        assert_eq!(store.take_flash(ALEX).unwrap().text, "Assigned Alex");
         store
-            .edit("alex", 4, Change::ToggleAssignee("alex".into()))
+            .edit(ALEX, 4, Change::ToggleAssignee(ALEX.into()))
             .unwrap();
         assert!(values(&store, 4).assignees.is_empty());
-        assert_eq!(store.take_flash("alex").unwrap().text, "Unassigned Alex");
+        assert_eq!(store.take_flash(ALEX).unwrap().text, "Unassigned Alex");
         assert!(matches!(
-            store.edit("alex", 4, Change::ToggleAssignee("eli".into())),
+            store.edit(ALEX, 4, Change::ToggleAssignee("eli".into())),
             Err(Error::NotFound("user"))
         ));
     }
@@ -801,11 +799,11 @@ mod tests {
     fn bad_edits() {
         let store = fixtures::store().unwrap();
         assert!(matches!(
-            store.edit("sam", 999, Change::State(State::Do)),
+            store.edit(SAM, 999, Change::State(State::Do)),
             Err(Error::NotFound("message"))
         ));
         assert!(matches!(
-            store.edit("sam", 2, Change::State(State::Do)),
+            store.edit(SAM, 2, Change::State(State::Do)),
             Err(Error::BadRequest(_))
         ));
     }
@@ -814,16 +812,16 @@ mod tests {
     fn comments() {
         let store = fixtures::store().unwrap();
         let before = store.timeline(1).unwrap().len();
-        store.add_comment("sam", 1, "  Called them.  ").unwrap();
+        store.add_comment(SAM, 1, "  Called them.  ").unwrap();
         let timeline = store.timeline(1).unwrap();
         assert_eq!(timeline.len(), before + 1);
         assert!(matches!(timeline.last(), Some(Item::Comment(c)) if c.text == "Called them."));
         assert!(matches!(
-            store.add_comment("sam", 1, "   "),
+            store.add_comment(SAM, 1, "   "),
             Err(Error::BadRequest(_))
         ));
         assert!(matches!(
-            store.add_comment("sam", 999, "hi"),
+            store.add_comment(SAM, 999, "hi"),
             Err(Error::NotFound("thread"))
         ));
     }
@@ -846,13 +844,13 @@ mod tests {
     #[test]
     fn read_tracking() {
         let store = fixtures::store().unwrap();
-        assert!(store.unread("alex").unwrap().contains(&fixtures::WATER));
-        store.mark_read("alex", fixtures::WATER).unwrap();
-        store.mark_read("alex", fixtures::WATER).unwrap();
-        assert!(!store.unread("alex").unwrap().contains(&fixtures::WATER));
-        assert!(store.unread("sam").unwrap().contains(&fixtures::WATER));
+        assert!(store.unread(ALEX).unwrap().contains(&fixtures::WATER));
+        store.mark_read(ALEX, fixtures::WATER).unwrap();
+        store.mark_read(ALEX, fixtures::WATER).unwrap();
+        assert!(!store.unread(ALEX).unwrap().contains(&fixtures::WATER));
+        assert!(store.unread(SAM).unwrap().contains(&fixtures::WATER));
         // Sent messages are never unread.
-        assert!(!store.unread("sam").unwrap().contains(&2));
+        assert!(!store.unread(SAM).unwrap().contains(&2));
     }
 
     #[test]
@@ -863,7 +861,7 @@ mod tests {
         assert_eq!(sent.cc, ["Alex"]);
         assert_eq!(sent.bcc, ["Pat Lee"]);
         assert!(
-            matches!(&sent.kind, Kind::Sent { by, to } if by == "sam" && to == &["Northwind Roofing"])
+            matches!(&sent.kind, Kind::Sent { by, to } if by == SAM && to == &["Northwind Roofing"])
         );
         let got = store.message(5).unwrap().unwrap();
         assert_eq!(got.at, jiff::civil::date(2026, 9, 30).at(15, 30, 0, 0));
@@ -872,7 +870,7 @@ mod tests {
             Some(&Values {
                 state: State::Do,
                 folder: Some("School".into()),
-                assignees: BTreeSet::from(["sam".to_owned()]),
+                assignees: BTreeSet::from([SAM.to_owned()]),
             })
         );
         assert!(store.message(999).unwrap().is_none());
@@ -890,19 +888,15 @@ mod tests {
     #[test]
     fn lookups() {
         let store = fixtures::store().unwrap();
-        let slugs: Vec<_> = store.users().unwrap().into_iter().map(|u| u.slug).collect();
-        assert_eq!(slugs, ["alex", "sam"]);
-        assert_eq!(store.user("sam").unwrap().unwrap().name, "Sam");
-        assert!(store.user("pat").unwrap().is_none());
-        assert_eq!(
-            store
-                .user_by_login("alex@example.com")
-                .unwrap()
-                .unwrap()
-                .slug,
-            "alex"
-        );
-        assert!(store.user_by_login("nobody").unwrap().is_none());
+        let logins: Vec<_> = store
+            .users()
+            .unwrap()
+            .into_iter()
+            .map(|u| u.login)
+            .collect();
+        assert_eq!(logins, [ALEX, SAM]);
+        assert_eq!(store.user(SAM).unwrap().unwrap().slug, "Sam");
+        assert!(store.user("pat@example.com").unwrap().is_none());
         assert_eq!(
             store.thread(1).unwrap().unwrap().subject,
             "Gutter repair estimate"
@@ -912,6 +906,23 @@ mod tests {
         assert!(store.thread_account(999).unwrap().is_none());
     }
 
+    #[test]
+    fn signing_in_adds_users_and_follows_their_slug() {
+        let store = Store::open_in_memory(fixtures::now()).unwrap();
+        assert!(store.is_empty().unwrap());
+        let pat = store.sign_in("pat@example.com", None).unwrap();
+        assert_eq!(pat, User::new("pat@example.com", None));
+        assert!(!store.is_empty().unwrap());
+        assert_eq!(store.user("pat@example.com").unwrap(), Some(pat));
+
+        store.sign_in("pat@example.com", Some("Pat")).unwrap();
+        store.sign_in("pat@example.com", Some("Pat")).unwrap();
+        assert_eq!(store.user("pat@example.com").unwrap().unwrap().slug, "Pat");
+        // Slugs are only for show, so two logins may share one.
+        store.sign_in("pat@work.example", Some("Pat")).unwrap();
+        assert_eq!(store.users().unwrap().len(), 2);
+    }
+
     #[test]
     fn importing_into_a_missing_thread_fails() {
         let store = fixtures::store().unwrap();
@@ -933,28 +944,28 @@ mod tests {
         assert!(store.is_empty().unwrap());
         fixtures::seed(&store).unwrap();
         assert!(!store.is_empty().unwrap());
-        store.edit("sam", 4, Change::State(State::Wait)).unwrap();
+        store.edit(SAM, 4, Change::State(State::Wait)).unwrap();
         store
-            .edit("sam", 4, Change::ToggleAssignee("alex".into()))
+            .edit(SAM, 4, Change::ToggleAssignee(ALEX.into()))
             .unwrap();
-        store.add_comment("sam", 1, "Called them.").unwrap();
-        store.mark_read("sam", fixtures::WATER).unwrap();
+        store.add_comment(SAM, 1, "Called them.").unwrap();
+        store.mark_read(SAM, fixtures::WATER).unwrap();
         drop(store);
 
         let store = Store::open(&path, fixtures::now()).unwrap();
         assert!(!store.is_empty().unwrap());
         let v = values(&store, 4);
         assert_eq!(v.state, State::Wait);
-        assert_eq!(v.assignees, BTreeSet::from(["alex".to_owned()]));
+        assert_eq!(v.assignees, BTreeSet::from([ALEX.to_owned()]));
         assert!(matches!(
             store.timeline(1).unwrap().last(),
             Some(Item::Comment(c)) if c.text == "Called them."
         ));
-        assert!(!store.unread("sam").unwrap().contains(&fixtures::WATER));
+        assert!(!store.unread(SAM).unwrap().contains(&fixtures::WATER));
         assert_eq!(store.history(4).unwrap().len(), 2);
         // Undo and toasts belong to the session that made the edit.
-        assert!(store.take_flash("sam").is_none());
-        assert!(matches!(store.undo("sam"), Err(Error::BadRequest(_))));
+        assert!(store.take_flash(SAM).is_none());
+        assert!(matches!(store.undo(SAM), Err(Error::BadRequest(_))));
     }
 
     #[test]
@@ -997,29 +1008,29 @@ mod tests {
         );
         // The state update goes in before the history row that fails.
         assert!(matches!(
-            store.edit("sam", 4, Change::State(State::Do)),
+            store.edit(SAM, 4, Change::State(State::Do)),
             Err(Error::Db(_))
         ));
         assert!(matches!(
-            store.edit("sam", 4, Change::ToggleAssignee("alex".into())),
+            store.edit(SAM, 4, Change::ToggleAssignee(ALEX.into())),
             Err(Error::Db(_))
         ));
         assert_eq!(values(&store, 4).state, State::Inbox);
         assert!(values(&store, 4).assignees.is_empty());
         assert!(store.history(4).unwrap().is_empty());
-        assert!(store.take_flash("sam").is_none());
+        assert!(store.take_flash(SAM).is_none());
 
         // A failed undo stays undoable.
         exec(&store, "DROP TRIGGER no_history;");
-        store.edit("sam", 4, Change::State(State::Do)).unwrap();
+        store.edit(SAM, 4, Change::State(State::Do)).unwrap();
         exec(
             &store,
             "CREATE TRIGGER no_history BEFORE INSERT ON history BEGIN SELECT RAISE(ABORT, 'no'); END;",
         );
-        assert!(matches!(store.undo("sam"), Err(Error::Db(_))));
+        assert!(matches!(store.undo(SAM), Err(Error::Db(_))));
         assert_eq!(values(&store, 4).state, State::Do);
         exec(&store, "DROP TRIGGER no_history;");
-        store.undo("sam").unwrap();
+        store.undo(SAM).unwrap();
         assert_eq!(values(&store, 4).state, State::Inbox);
     }
 
@@ -1029,25 +1040,26 @@ mod tests {
         let msg = store.message(4).unwrap().unwrap();
         exec(&store, "PRAGMA query_only = ON;");
         let db_err = |r: Result<(), Error>| assert!(matches!(r, Err(Error::Db(_))), "{r:?}");
-        db_err(store.edit("sam", 4, Change::State(State::Do)));
-        db_err(store.mark_read("sam", 4));
-        db_err(store.add_comment("sam", 1, "hi"));
+        db_err(store.edit(SAM, 4, Change::State(State::Do)));
+        db_err(store.mark_read(SAM, 4));
+        db_err(store.sign_in("pat@example.com", None).map(|_| ()));
+        db_err(store.add_comment(SAM, 1, "hi"));
         db_err(store.import(|tx| tx.folder("Travel")));
         db_err(store.import(|tx| tx.message(&msg)));
         exec(&store, "PRAGMA query_only = OFF;");
 
         // Each table the store reads, gone in turn.
         exec(&store, "ALTER TABLE folders RENAME TO gone_folders;");
-        db_err(store.edit("sam", 4, Change::Folder(Some("House".into()))));
+        db_err(store.edit(SAM, 4, Change::Folder(Some("House".into()))));
         assert!(store.folders().is_err());
         exec(&store, "ALTER TABLE comments RENAME TO gone_comments;");
         assert!(store.timeline(1).is_err());
         exec(&store, "ALTER TABLE reads RENAME TO gone_reads;");
-        assert!(store.unread("sam").is_err());
+        assert!(store.unread(SAM).is_err());
         exec(&store, "ALTER TABLE history RENAME TO gone_history;");
         assert!(store.history(4).is_err());
         exec(&store, "ALTER TABLE threads RENAME TO gone_threads;");
-        db_err(store.add_comment("sam", 1, "hi"));
+        db_err(store.add_comment(SAM, 1, "hi"));
         exec(&store, "ALTER TABLE users RENAME TO gone_users;");
         assert!(store.users().is_err());
         assert!(store.is_empty().is_err());
diff --git a/src/views.rs b/src/views.rs
index e2d5751..2f7f534 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -92,12 +92,12 @@ fn badge(state: State) -> Markup {
     html! { span.badge.(tone(state)) { span.mark {} (state.name()) } }
 }
 
-/// The user's display name, or the slug when nobody has it.
-fn user_name<'a>(users: &'a [User], slug: &'a str) -> &'a str {
+/// The short name shown for a login, or the login when nobody has it.
+fn user_name<'a>(users: &'a [User], login: &'a str) -> &'a str {
     users
         .iter()
-        .find(|u| u.slug == slug)
-        .map_or(slug, |u| u.name.as_str())
+        .find(|u| u.login == login)
+        .map_or(login, |u| u.slug.as_str())
 }
 
 fn assignee_label(users: &[User], values: &Values) -> Option<String> {
@@ -170,7 +170,7 @@ fn sidebar(p: &Page<'_>) -> Markup {
 
 fn whoami(p: &Page<'_>) -> Markup {
     let who = html! {
-        span.who-name { (p.me.name) }
+        span.who-name { (p.me.slug) }
         span.type-label.who-login { (p.me.login) }
     };
     html! {
@@ -180,9 +180,9 @@ fn whoami(p: &Page<'_>) -> Markup {
                 form.menu.up method="post" action="/dev/user" x-show="open" x-cloak {
                     (back(p.here))
                     @for user in &p.users {
-                        @let on = user.slug == p.me.slug;
-                        button.opt.cur[on] type="submit" name="user" value=(user.slug) {
-                            span { (user.name) }
+                        @let on = user.login == p.me.login;
+                        button.opt.cur[on] type="submit" name="user" value=(user.login) {
+                            span { (user.slug) }
                             span.check { @if on { "✓" } }
                         }
                     }
@@ -427,9 +427,9 @@ fn values_controls(p: &Page<'_>, id: u32, values: &Values, read_only: bool) -> M
     let assigned = assignee_label(&p.users, values);
     let people = html! {
         @for user in &p.users {
-            @let on = values.assignees.contains(&user.slug);
-            button.opt.cur[on] type="submit" name="user" value=(user.slug) {
-                span { (user.name) } (check(on))
+            @let on = values.assignees.contains(&user.login);
+            button.opt.cur[on] type="submit" name="user" value=(user.login) {
+                span { (user.slug) } (check(on))
             }
         }
     };
diff --git a/tests/api.rs b/tests/api.rs
index b76b080..14d5fd1 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -66,14 +66,37 @@ async fn health_returns_ok() {
 }
 
 #[tokio::test]
-async fn requires_a_known_identity() {
+async fn requires_an_identity() {
     let addr = spawn().await;
     let res = reqwest::get(format!("http://{addr}/")).await.unwrap();
     assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
-    let (status, _) = get(addr, "eve@example.com", "/").await;
+    let (status, _) = get(addr, "", "/").await;
     assert_eq!(status, StatusCode::UNAUTHORIZED);
 }
 
+#[tokio::test]
+async fn anyone_tailscale_lets_in_is_a_user() {
+    let addr = spawn().await;
+    let (status, body) = get(addr, "eve@example.com", "/").await;
+    assert_eq!(status, StatusCode::OK);
+    assert!(body.contains(r#"<span class="who-name">eve</span>"#));
+
+    // The proxy names them with X-User-Slug, and the name follows it.
+    let body = client()
+        .get(format!("http://{addr}/?m=4"))
+        .header("Tailscale-User-Login", "eve@example.com")
+        .header("X-User-Slug", "Evie")
+        .send()
+        .await
+        .unwrap()
+        .text()
+        .await
+        .unwrap();
+    assert!(body.contains(r#"<span class="who-name">Evie</span>"#));
+    // Once in, they can be assigned like anyone else.
+    assert!(body.contains(r#"value="eve@example.com"><span>Evie</span>"#));
+}
+
 #[tokio::test]
 async fn for_me_lists_messages_grouped_by_state_and_thread() {
     let addr = spawn().await;
@@ -95,7 +118,7 @@ async fn lanes_and_search() {
         ("/do", "Exemption renewal"),
         ("/wait", "Claim 4471"),
         ("/watch", "Shipped: furnace filters"),
-        ("/search?q=downspout", "Sam → Northwind Roofing"),
+        ("/search?q=downspout", "sam → Northwind Roofing"),
     ] {
         let (status, body) = get(addr, SAM, path).await;
         assert_eq!(status, StatusCode::OK, "{path}");
@@ -232,7 +255,7 @@ async fn editing_values_with_undo() {
         addr,
         SAM,
         "/messages/4/assignees",
-        &[("user", "alex"), ("back", "/")],
+        &[("user", ALEX), ("back", "/")],
     )
     .await;
     assert_eq!(res.status(), StatusCode::SEE_OTHER);
@@ -299,7 +322,7 @@ async fn cross_site_posts_are_refused() {
             .post(format!("http://{addr}{path}"))
             .header("Tailscale-User-Login", SAM)
             .header("Origin", origin)
-            .form(&[("state", "do"), ("user", "alex"), ("back", "/")])
+            .form(&[("state", "do"), ("user", ALEX), ("back", "/")])
             .send()
     };
     let res = send("https://evil.example", "/messages/4/state")
@@ -332,18 +355,18 @@ async fn dev_mode_picks_a_user_from_a_cookie() {
 
     let res = anon
         .post(format!("http://{addr}/dev/user"))
-        .form(&[("user", "sam"), ("back", "/do")])
+        .form(&[("user", SAM), ("back", "/do")])
         .send()
         .await
         .unwrap();
     assert_eq!(res.status(), StatusCode::SEE_OTHER);
     assert_eq!(location(&res), "/do");
     let cookie = res.headers()["set-cookie"].to_str().unwrap();
-    assert!(cookie.starts_with("docket_dev_user=sam;"));
+    assert!(cookie.starts_with("docket_dev_user=sam@example.com;"));
 
     let body = anon
         .get(format!("http://{addr}/"))
-        .header("Cookie", "theme=dark; docket_dev_user=sam")
+        .header("Cookie", "theme=dark; docket_dev_user=sam@example.com")
         .send()
         .await
         .unwrap()
@@ -362,7 +385,7 @@ async fn dev_mode_picks_a_user_from_a_cookie() {
 
     // Outside dev mode, the switch doesn't exist.
     let prod = spawn().await;
-    let res = post(prod, SAM, "/dev/user", &[("user", "alex"), ("back", "/")]).await;
+    let res = post(prod, SAM, "/dev/user", &[("user", ALEX), ("back", "/")]).await;
     assert_eq!(res.status(), StatusCode::NOT_FOUND);
 }