LFSX does not manage accounts. It asks the forge that hosts the repository what the caller is allowed to do, so the answer is always the same one the repository gives.
The client presents the token it would use to clone over HTTPS — a personal access token, or the
GITHUB_TOKEN a CI job already has — as the password of an HTTP Basic credential, or as a bearer
token. LFSX resolves it against GET /repos/{org}/{repo} and maps the result:
| GitHub | GitLab | Objects |
|---|---|---|
| admin | Maintainer, Owner | download, upload, and force a lock open |
| push | Developer | download, upload, and take locks |
| pull only | Reporter | download |
| none, or an unusable token | Guest, or none | rejected |
GitLab grants inherited from a group count the same as ones set on the project, which is how most organisations there are arranged. Developer is the level that may push, matching what GitLab itself requires to write to the repository.
/health stays open. Everything under /{org}/{repo}/objects/ requires a token, and each answer
is cached for LFSX_AUTH_CACHE_TTL seconds so a push of two hundred objects costs one API call
rather than two hundred. That cache is also the delay before a revocation takes effect — shorten
it if that matters more than the round trips.
Refusals are remembered too, for the shorter LFSX_AUTH_REJECTION_TTL. Without that, a CI job
retrying with a revoked token spends one API call per attempt, forever, against the same budget
the server needs for real lookups — and an unauthenticated caller could drive that load on
purpose. The window is short on purpose: it is how long you keep being refused after being granted
access. A forge that cannot be reached is never cached, so an outage stays an outage rather than
becoming a lasting denial.
Git already sends the token if it is in the credential store for that host:
git config --global credential.https://lfs.example.com.username git
printf 'protocol=https\nhost=lfs.example.com\nusername=git\npassword=%s\n' "$GITHUB_TOKEN" \
| git credential approve
In CI, the token the workflow already holds is enough — no secret to provision:
- run: |
printf 'protocol=https\nhost=lfs.example.com\nusername=git\npassword=%s\n' "${{ secrets.GITHUB_TOKEN }}" \
| git credential approve
git lfs pull
Adding a forge
Two providers exist, so the shape is settled rather than guessed. A provider is one module under
server/src/auth/ exposing two functions — permission(client, api_url, token, namespace) and
login(client, api_url, token) — plus a variant on config::Provider carrying its default API
root and environment variable, and two arms in auth.rs. Nothing else: the caching, the challenge
handling and the rejection accounting are shared and provider-blind.
The part worth care is the error mapping, because it is where the two existing providers already
disagree. GitHub answers 403 when rate-limited, GitLab answers 429; both mean "ask again
later" and must map to Error::RateLimited, never to Forbidden. Getting that wrong tells a user
with full rights that they have none.
Error::RateLimited and Error::Forge are separate on purpose, and a new provider should keep them
apart. A throttled forge is working: it has said when to come back, and the answer is a 503
carrying Retry-After so the client waits. 502 reads as a transient upstream failure and git-lfs
comes straight back, spending another request on the same exhausted quota, which turns one rate
limit into a CI run's worth of them. The duration comes from Retry-After when the forge sends one,
from x-ratelimit-reset when it sends an absolute reset instead, and from a one-minute default when
it says neither: never from zero. lfsx_rejections_total{cause="forge_rate_limited"} counts these
separately from forge_unreachable, because "the forge is throttling us" and "the forge is broken"
are different afternoons.
Gitea is the obvious third, tracked in the issues.
Why authentication cannot live in the proxy
This is not obvious, and it rules out the approach most people reach for first.
The batch response carries the URLs the client will use for each object transfer. If the server
advertises them as already authenticated — "authenticated": true — without supplying an
Authorization header alongside, git-lfs treats those URLs as pre-signed storage links and calls
them with no credentials at all. Behind an authenticating proxy, every one of those calls
returns 401, and the client retries in a loop.
This is exactly what makes rudolfs unusable behind
Traefik BasicAuth: it answers "authenticated": true with "header": null and offers no way to
change that.
LFSX never emits authenticated for a URL that points back at itself, so the client authenticates
each transfer itself. A test pins the behaviour down —
batch_never_claims_a_transfer_through_this_server_is_pre_authenticated — and it will fail if
anyone sets the field on the ordinary path.
The exception proves the rule rather than bending it. With LFSX_S3_PRESIGN=true the href is a
pre-signed bucket URL, which genuinely carries its own credentials and genuinely must be called
without an Authorization header — the proxy in front of this server is not even in that path. The
field is not a claim about the server's own authentication; it is a claim about the URL, and it is
set only when the URL was signed.
Authentication therefore lives in the server, which is what the section above describes.