| 1 | # 0007 — Personal access tokens over HTTP Basic, with a uniform 401 |
| 2 | |
| 3 | **Status:** accepted · **Date:** 2026-08-28 |
| 4 | |
| 5 | ## Context |
| 6 | |
| 7 | Milestone 4a serves `git clone` for public repositories to anonymous callers. |
| 8 | Everything else the git transport should eventually do — pushing, and cloning a private |
| 9 | repository — needs to know who is asking. |
| 10 | [0001](0001-git-over-http-not-ssh.md) already chose the credential: personal access |
| 11 | tokens over HTTP Basic. What it did not settle is how they are stored, what they are |
| 12 | allowed to do, and — the one with a real cost either way — what an unauthenticated |
| 13 | request is told. |
| 14 | |
| 15 | That last question is forced by the client. **Git only sends credentials after a 401.** |
| 16 | Milestone 4a answers 404 for a private repository, matching `view_repo`'s rule that an |
| 17 | invisible repository is absent rather than forbidden. Keep that, and an authenticated |
| 18 | private clone is impossible: the client is told the repository does not exist and never |
| 19 | offers a credential. |
| 20 | |
| 21 | ## Decision |
| 22 | |
| 23 | **Tokens are SHA-256 hashed, carry no scope, and any git request that is not |
| 24 | anonymously readable answers 401 — whether or not the repository exists.** |
| 25 | |
| 26 | - **Hashing: SHA-256, with a display prefix stored alongside.** Mirrors |
| 27 | `SessionTokenHash`. A token is 256 bits from the OS random source, so there is no |
| 28 | dictionary for a slow hash to defend against, and this credential is verified on every |
| 29 | request of a clone — several per `git clone`. Argon2 is the right answer for a |
| 30 | human-chosen password and the wrong one here. The first eight characters are kept in |
| 31 | the clear so a management UI can name a token it can no longer show. |
| 32 | - **No scopes.** A token acts as the user who issued it. Personal-first means one |
| 33 | person's own credential; narrowing it is a change to make when something needs it, |
| 34 | behind an unchanged port. |
| 35 | - **A uniform 401.** Any git path the caller may not read anonymously answers `401` with |
| 36 | `WWW-Authenticate: Basic`, including repositories that do not exist. Nothing in the |
| 37 | response distinguishes "private" from "absent". |
| 38 | |
| 39 | ## Alternatives considered |
| 40 | |
| 41 | - **401 only for repositories that exist**, 404 otherwise. No spurious credential prompt |
| 42 | on a typo. Rejected because the pair of responses is itself the leak: probing tells an |
| 43 | attacker which private repository names exist, which is the thing private visibility is |
| 44 | protecting. The prompt-on-typo cost is a nuisance; the leak is a defect. |
| 45 | - **Keep 404 and require credentials up front.** Leaks nothing, and git never prompts — |
| 46 | a private clone works only after configuring a credential helper. Rejected as hostile |
| 47 | to the ordinary case, and it makes the documented instructions longer than the feature. |
| 48 | - **Argon2, reusing the existing `PasswordHasher` port.** One credential mechanism |
| 49 | instead of two. Rejected on cost: deliberately slow hashing on every request of every |
| 50 | clone, defending against a dictionary attack that cannot exist against 256 random bits. |
| 51 | - **Read-only versus read-write scopes now.** Genuinely useful — a CI token that can |
| 52 | clone but not push. Rejected as a second authorization axis crossing the one that |
| 53 | already exists, designed against no requirement. Cheap to add later. |
| 54 | |
| 55 | ## Consequences |
| 56 | |
| 57 | - **A typo'd clone URL prompts for a password before reporting `not found`.** The |
| 58 | accepted cost of the uniform 401, and the same behaviour GitHub has. |
| 59 | - **The 404 rule now has an exception, and it is transport-shaped.** `view_repo` still |
| 60 | answers "absent" for a page; the git routes answer 401. Two rules for one question, so |
| 61 | the reason lives here rather than being rediscovered as an inconsistency. |
| 62 | - **A lost token cannot be recovered**, only replaced. That is the point of storing a |
| 63 | hash, and the UI has to show the token exactly once and say so. |
| 64 | - **Revocation is a delete.** A flag would mean every read has to remember to check it. |
| 65 | - **Tokens authenticate; they do not authorize.** `serve_git` remains the one place that |
| 66 | decides what an actor may do, so a token widens who the actor is and changes nothing |
| 67 | about the rules. |
| 68 | - **Reversible where it matters:** scopes can be added behind `TokenRepository` |
| 69 | unchanged. The 401 rule is the part that is expensive to revisit, because it is |
| 70 | observable behaviour that clients and instructions come to depend on. |