Security hardening guide
Issued-key access: Root, dated branch node keys and the person’s own personal key can open coaching cards. Each real read is audited before construction; declarations, static/shared tokens and a positive-spend prerequisite are retired. See identity access for issued-key setup and retention.
This guide is written for the person deploying TIER and for the security
reviewer doing diligence on it. TIER's security model is a set of deliberate,
documented trade-offs: they are safe only when the operator understands
them. Every behavioral claim below carries a (code: ...) pointer so you can
verify it against the source in minutes rather than take it on faith.
Scope note: TIER today is single-process, single-tenant. There is no
per-organization isolation — developer identifiers are global. Do not deploy a
single tierd as a shared multi-organization service.
Related reading:
- privacy.md — the full, code-grounded account of what TIER reads and stores (this guide summarizes its headline guarantee).
- legal-and-privacy.md — deploying where per-developer measurement is legally constrained.
- webhook-setup.md — configuring the GitHub webhook.
- conventions.md — the attribution conventions TIER depends on.
1. What TIER stores — and what it never stores
TIER never persists the prompt or completion content of the usage it captures. The token-capture path parses provider responses and Claude Code session files through an allowlist: from that path, only token counts, model names, timestamps, and attribution identifiers (developer, issue, repository) reach disk. Prompt text, completion text, source code, file contents, and diffs are never deserialized and never stored. The optional reverse proxy is the one path that carries them: by design it forwards your requests to the provider and the responses back to you, reading each response in memory as it passes to find its usage. It sends them nowhere else. The one exception to never persisting such text is the free text of raw GitHub webhook payloads, stored as written, with whatever was pasted into it (below).
Why the guarantee holds — the parsers are allowlist structs, so any field not named in the struct is dropped by the JSON decoder before it exists in memory:
- JSONL session collector:
jsonlEntrydecodes onlytype,timestamp,gitBranch,cwd,sessionId, andmessage.{id,model,role,usage.*}— usage is token counts, not text (code:internal/collector/jsonl.go,jsonlEntry). With worktree attribution on (--worktree-attribution, off by default, #823), a separate allowlist,toolPathLine, also decodes each content block's type and tool name and thefile_path,notebook_pathandpathvalues of every tool call's input, whatever the tool, and keeps one path per call for seven tools; the rest of the input and every command line are skipped unread (code:internal/collector/toolpath.go; detail in privacy.md). Parents never use carry; onlyisSidechain:truegroups can carry (#1016). The feature stays off by default; activation requires recorded gates, operator security rulings and an explicit operator decision. No new accuracy figures have been measured. - Reverse proxies: the response parsers extract only
id,model, andusagefrom provider response bodies (code:internal/proxy/proxy.go). - Seam-capture fixtures enforce the same allowlist for any captured test data
(code:
testdata/seam-jsonl-ingestion/README.md).
What the capture path persists is the token_events table. Its columns are
defined once, in internal/store/store.go (schemaTables, the
CREATE TABLE token_events statement), and none holds prompt or completion
text. Which stored fields can identify a person, and how, is listed once in
privacy.md § What IS stored.
Branch names are read into memory to derive issue_id, and only the derived
identifier is written to a token_events row; the branch string is never stored
there (code: internal/store/store.go, the token-event insert path). The Claude
Code watcher's resume checkpoint (watcher_checkpoint) does store branch names,
the working directory and the session id; privacy.md lists its full contents
(code: internal/collector/watcher.go, checkpointFromState).
The webhook table stores third-party free text, as written. When the
GitHub webhook path is enabled, TIER retains the raw GitHub webhook body
(gzipped) for every processed delivery in the webhook_payloads table, so a
score input can be re-derived later (code: internal/store/store.go,
webhook_payloads; internal/webhook/handler.go, InsertWebhookPayload;
#137). That body does contain personal data: commit author names and email
addresses, PR titles and descriptions, and commit messages — the same PR/commit
metadata GitHub itself already stores. It is bounded by a 90-day age cap and a
50,000-row cap (code: internal/store/audit.go, PruneWebhookPayloads), stored
in the same 0600 single-tenant database, and is deliberately excluded from
the GDPR erase route (DELETE /api/v1/developer/{id}), so a contributor name
embedded in an old payload is aged out by retention, not by erasure (code:
internal/store/store.go, EraseDeveloper table list). It is the only table
that holds third-party PR/commit text; that text is stored as written, so it can
hold whatever someone pasted into it (privacy.md),
and it exists only when you run the webhook. It is not the only
free text at rest: privacy.md lists every other stored field,
including the client-chosen and operator-supplied strings, and walks through the
allowlist in more depth.
2. Issued keys and least authority
Every protected real-server API and proxy request resolves a current key from
identity_credentials. Root sees the whole organization; node keys stay in
their dated branch and personal keys see their holder. Read-only node keys never
write. Metrics keys scrape /metrics only; root may also scrape. Device/upload
and personal keys can upload only as their subject. Proxy admission accepts
personal/upload keys and strips X-Tier-Token before forwarding upstream.
Bootstrap root on the server host with tierd tokens issue --db PATH --root.
The same host command supports --metrics or --upload --subject ID; inventory
and id-based revocation expose no secret/verifier material. Never put a key in
logs, audit detail, a literal command-line argument or shell history. Client
bearer flags may read @/path/to/file; browser sign-in retains only an in-memory
session and re-resolves the stored key on each request.
Shared read, legacy admin, viewer and static metrics tokens are removed. There
is no declaration-based permission. Retired server flags and YAML fail strict
parsing; nonempty retired environment settings fail startup. Historical
shared_upload provenance remains uncounted evidence and is not a credential.
3. Always-on boundary and synthetic demo
There is no auth-off listener setting. Public health/version probes expose no
figures. Only tierd demo opens anonymous reports, on its own invented database;
its proxy and production write routes are absent. Offline raw-manifest replay
is not a server listener and cannot change real-server authorization.
Sign-in requires loopback or trusted HTTPS termination, exact-origin checks and server-held CSRF for cookie mutations. Bearer scripts use the same key table. IP lockout, per-key read rates, holder/dated-branch checks and audit-before-read remain mandatory. Failed view-log or read-audit admission releases no figures. Moves preserve dated history, refuse non-root self-grants and atomically notify subjects when effective holder access changes. Erasure and DSAR still cover keys, view history, retained notices and historical audit names.
4. Filesystem hardening
The SQLite database and its -wal / -shm sidecars are created mode 0600
(owner read/write only), and legacy files are repaired to 0600 on every
start (code: internal/store/store.go, Open; #130). Files are created tight
(O_CREATE with 0600), so there is no transient world-readable window, and a
chmod sweep at the end of Open tightens any pre-existing 0644 file. The
containing directory is created 0700 (code: cmd/tierd/main.go) — but if you
pre-create the state directory yourself, MkdirAll leaves its existing mode
untouched, so set it 0700 in that case. You do not need to chmod the
database by hand.
Caveats:
- POSIX only. The
0600guarantee relies on POSIX mode bits. On Windowsos.Chmodonly toggles the read-only attribute, so this protection does not apply there. - Re-tightened every restart. Because
Openre-applies0600on each start, a manual relaxation does not stick. A backup agent or group that needs to read the DB must run as thetierduser, not rely on group-readable bits. - Run
tierdunder a dedicated, unprivileged user whose home holds the state directory, so the0700/0600owner-only permissions actually isolate the data from other accounts on the host. - Backups inherit the sensitivity. Any copy of the DB (or its sidecars) holds the same per-developer spend and attribution data. Protect the backup destination exactly as you protect the live file — the mode bits do not travel with a copy you make yourself.
5. Rate-limit lockout topology
tierd has a per-IP failed-authentication lockout: after --auth-max-failures
failures within --auth-failure-window, the offending IP is locked out with a
429 for --auth-lockout (defaults: 10 failures / 60s → 15 minutes) (code:
internal/api/ratelimit.go, DefaultRateLimitConfig). It keys on the direct
TCP peer and, by default, ignores X-Forwarded-For — because a client
controls that header, and honoring it would let an attacker mint unlimited
lockout buckets and defeat the limiter entirely (code:
internal/api/ratelimit.go, clientIP).
Consequence behind a shared TLS terminator, reverse proxy, or NAT: every client arrives from the same direct peer address, so they all share one lockout bucket. A single misconfigured client sending bad tokens can lock out the whole organization for the lockout window.
Mitigations, in order of preference:
- Configure
--trusted-proxy-cidr(shipped, #131). When the direct peer is inside a trusted CIDR you supply, the limiter instead keys on the real client fromX-Forwarded-For— specifically the rightmost hop not inside a trusted CIDR, which is the address your own edge appended and thus the one value a client cannot forge (code:cmd/tierd/main.go,--trusted-proxy-cidr;internal/api/ratelimit.go,clientIP). This flag is also settable in the config file ashttp.trusted_proxy_cidrs. Set it to your terminator's address range and per-client lockout is restored — this works only if your edge actually sets a trustworthyX-Forwarded-For; if the trusted peer forwards no XFF,clientIPfalls back to the shared peer address. - Fix the failing client (it is usually a stale or wrong token).
- Raise
--auth-max-failures, or rate-limit at your terminator instead.
Note: the limiter keys on the full client address; there is no coarser subnet
bucketing, so distinct clients get distinct buckets once --trusted-proxy-cidr
is configured correctly.
6. Webhook authentication
The GitHub webhook is authenticated with HMAC-SHA256 over the request body,
validated against the X-Hub-Signature-256 header on every request (code:
internal/webhook/handler.go, verifySignature). It is fail-closed: with
no TIER_WEBHOOK_SECRET configured, the route is not even mounted, and if the
handler is reached without a secret it rejects every request with 403 (code:
cmd/tierd/main.go, webhook mount guard; internal/webhook/handler.go, #60).
An unauthenticated webhook would let anyone who can reach the listener forge
merged-PR outcomes.
Supply the secret via TIER_WEBHOOK_SECRET or the @/path/to/file indirection
so it stays out of ps output and shell history (code: cmd/tierd/main.go,
#37). See webhook-setup.md for the GitHub-side
configuration.
7. Proxy header hygiene
The reverse proxies attribute spend from three internal request headers —
X-Tier-Developer, X-Tier-Issue, and X-Tier-Repo — and strip every
X-Tier-* header from the outbound request before forwarding upstream, so none
reaches the provider (code: internal/proxy/proxy.go, the Rewrite hook). That
includes the X-Tier-Token proxy credential; it is always checked against
an active personal/upload credential before forwarding (code:
internal/api/handler.go, ProxyAuth). A non-canonical X-Tier-Repo value is
ignored and never logged.
8. Log hygiene (log-injection resistance)
Every value derived from an untrusted request that reaches a log record flows
through a sanitizer (internal/logsafe, wrapped at call sites as logSafeStr /
logSafeErr). It strips carriage returns and line feeds — the explicit
CR/LF removal that forms the forged-log-record barrier — then %q-quotes the
result to escape any remaining control bytes, invalid UTF-8, or quotes, and caps
the length. A structured logger plus this sanitizer means an attacker cannot
inject a newline to forge a second, attacker-authored log line.
A small, deliberate set of logged fields are left un-wrapped because they are
provably constrained by construction and cannot carry a control byte: GitHub
issue references (issue-<digits>), commit SHAs ([0-9a-f]{40}), the webhook
event type (allowlist-bounded to pull_request / push / workflow_run),
upstream-validated period strings, and numeric fields such as PR numbers. Each is
documented at its call site.
Errors are the sixth class, and the rule for them is narrower than "wrap
everything". An error is not exempt from the barrier just because it is an
error: a store or validation error can wrap a free-form identifier (a developer
id, org, team, or division that is length-capped but never charset-validated), and
those sinks go through logSafeErr — the hierarchy writes, the /outcomes insert,
and the per-developer score reads all do. The majority of err values are logged
bare instead, and the discriminator is whether the error's text can carry a
client value, not whether the operation touched one: the SQLite driver does not
echo bound parameters, so a failed INSERT whose arguments include a hostile
developer id still produces a constraint message that names only the table and
column. A sweep of the request-facing packages (internal/api,
internal/webhook, internal/proxy, cmd/tierd) found the values that are
interpolated into internal/store error text are either operator-supplied
(config route prefixes, DB paths, table names) or routed through logsafe at the
point of construction. Wrapping the rest would add noise and blur exactly the
client/operator distinction this barrier depends on.
An earlier revision of that sentence listed "price-table model names" among the
operator-supplied values. That was backwards, and it is corrected here rather
than quietly dropped because the error it excused was real. The model names
interpolated into store.Reprice's GUESS-gate error are by construction the
models absent from the price table — the producer-supplied strings that
matched no operator-curated entry, arriving through POST /api/v1/events, which
length-caps model and applies no charset check. They are now routed through
logsafe.Join at construction, pinned by
TestReprice_GuessGateErrorIsNotForgeable.
Note on static analysis: an automated code scanner may report these constrained bare fields as potential log-injection findings. They are false positives — the scanner cannot prove the regex/allowlist constraint that the code guarantees — and are triaged as such, rather than "fixed" by wrapping a value that already cannot forge a record.
What the barrier actually buys, measured (#321, 2026-08-04). Two honest qualifications, because "a sanitizer is applied" is not by itself a threat model:
- On a
log/sloghandler the CR/LF forge is already structurally blocked beforelogsaferuns: bothTextHandlerandJSONHandlerquote or escape any attribute value containing a control byte, so a newline cannot open a second record through them. Whatlogsafeadds on those sinks is the length cap and the static-analysis barrier. The cap is not theoretical: a 4 KB upstream header value reached one log line unbounded before this was fixed, which is whatTestProxy_ContentEncodingNotForgeablenow pins. - The CR/LF strip is load-bearing for the sinks that are not
slog— the operator-facing report writers incmd/tierdthat print to stdout, where nothing quotes — and for any log consumer that unquotes a field to render it. Escaping is reversible downstream; removal is not.
Code scanning checks this class on the development repo. The
go/log-injection query declares @precision medium, and GitHub's default
code-scanning suite includes only precision: high|very-high
(misc/suite-helpers/code-scanning-selectors.yml in the github/codeql repository), so the query runs only under
the extended suite. The private development repo, where every change is made,
runs CodeQL default setup with query_suite: "extended", so it runs this query
(measured 2026-09-25). The public mirror ran the same extended setup on
2026-08-04; on 2026-09-25, while the mirror was private, GitHub reported code
scanning unavailable there. Either way, a clean Security tab is not proof
that this section holds — the named tests are (internal/logsafe,
TestProxy_ContentEncodingNotForgeable, and the per-sink *NotForgeable tests
in internal/api, internal/webhook, internal/collector, internal/store,
and cmd/tierd).
⚠️ Read that list precisely, because it was once read too generously. Until
2026-08-04 the only such test in cmd/tierd covered the access log
(TestRequestLogger_PathNotForgeable), and no test covered a report writer —
a different sink class with a different threat model, since the access log is
slog (which escapes CR/LF even when the caller forgets) and a report writer is
raw fmt.Fprintf (which escapes nothing). The sentence read as coverage of a
class that had none, and cmd/tierd/reprice.go diverged from its
correctly-wrapped sibling repairrepo.go without a single test going red. The
report writers are now covered by TestPrintRepriceResult_NotForgeable and
TestPrintRepairRepoResult_NotForgeable.
9. Reporting a vulnerability
If you find a security issue in TIER, report it privately — do not open a public issue, and do not include a working exploit in a public channel.
Use GitHub's private vulnerability reporting on this repository (Security → Report a vulnerability), which opens a disclosure channel visible only to the maintainers.
The full disclosure policy, supported-version window, and response-time commitment are in SECURITY.md.
Child federation credentials and transport (P28 + P29, EXTREME)
The upstream bearer authorizes reports into one central place. A stolen bearer can forge that child's totals until central revokes and reissues it. Install it in an absolute host file owned by tierd's user with mode 0600. The containing directory must have the same owner and deny group/other writes. Token reads refuse symlinks, hard links, nonregular files and unsafe modes; pastes use a synced temporary 0600 file and atomic rename in a pinned directory. Unsupported platforms refuse credential I/O. The token is re-read for every request and never stored in SQLite or returned by the child API. No credential-derived diagnostic is recorded.
The host pins the central origin and issued server ID. HTTPS is required by default; root may allow plain HTTP for federation in Settings, exposing names, figures and the federation key unencrypted. Root browser/API settings cannot change the destination or token path. Restart refuses different pins for an existing upstream, so pending bytes cannot cross into another central or server identity. TLS certificate checks stay enabled, redirects are never followed, request bodies are at most 1 MiB, responses at most 64 KiB and requests time out after 15 seconds. Network errors and response bodies are never copied into logs, status or audit.
Sequence and pending body commit before send. Network failure, cancellation and restart retain the identical request for the central's exact-retry path. Only a matching acknowledgement advances the cursor; 409 never triggers a rewritten period or automatic new sequence. The worker respects persistent backoff, runs only in writable serving and joins the bounded shutdown before database close. The root-only connection test uses central status GET without changing sequence or push history. See API compatibility for configuration and route contracts.
Planned / not yet shipped
For accuracy, these are not current capabilities — do not rely on them:
- Legacy shared writes are unverified history. Current
/eventsand proxy admission require personal/upload keys and enforce their subject. Shared, static-admin and viewer credentials are retired. Root-posted manual costs provide no counted identity. Legacy shared uploads count zero toward k and named coaching evidence. See section 2. - Multi-tenant isolation. TIER is single-tenant; developer identifiers are global (see the scope note at the top).
Graft preview fetch residual
The root-only import preview makes one outbound HTTPS GET with a one-time export credential in the Authorization header. It follows no redirects, limits the body to 64 MiB (67,108,864 bytes), and bounds the fetch with a 30-second timeout. The destination checks both declared and streamed size before staging; over-limit responses return 413 with a size lower bound and the limit, without importing a truncated bundle. The server write timeout remains 60 seconds. URL user information, queries, fragments, and paths other than the bundle endpoint are refused. A large import can use about 1.4 GB of memory for a 64 MiB bundle, holds the database writer during staging and apply, and grows the write-ahead log. The 64 MiB wire limit does not bound these costs to 64 MiB.
EXTREME — concurrency and data loss during company imports (#1488, ruling A‴). While an import applies, live events wait and aren't dropped. They can be lost if the process dies, including being killed for memory on a small server, if the 30-minute cap passes, or if the write fails after the wait; TIER flags a report when that may have happened. Lost proxy events can't be recovered.
The lock estimate is a range: about 20 seconds per 100,000 events on a fast machine, about 60 on one core, and slower servers can take many times longer. Allow about 1.4 GB of memory for a 64 MiB bundle; a 1 GB server may be killed for memory, losing live events in flight. The byte cap does not bound memory to 64 MiB.
During the source pull and apply, proxy inserts wait on an in-process signal without a database connection. JSON and SSE responses complete while capture continues afterward, retaining only the event and tracked by the same shutdown drain. Outside imports, the existing 90-second budget and roughly 250 ms lock tries are unchanged. After the signal clears, that normal budget restarts. Preview and apply clear the 60-second HTTP write deadline after root auth. On SIGTERM, closing active HTTP connections cancels an unfinished import; capture drains before the store closes.
Each source pull or apply durably records its own unique attempt before its
lock. A failed record write refuses the operation. Every exit removes only its
own record with a detached context. Startup treats any leftover record as a
possible loss and sets the durable data_quality.live_capture_may_be_lost flag;
read-only startup reads that warning without writing it. Exhaustion and final
write failures also set the flag once a write is possible, increment
tier_proxy_writes_total{provider,outcome="error"}, and log an ERROR without
names, credentials or underlying error text.
Webhooks keep today's 503 on write-lock contention and must be manually
redelivered; GitHub does not automatically redeliver failures. Other routes,
including POST /api/v1/costs, retain their documented 503 and Retry-After
responses. The 64 MiB limit and all-or-nothing apply are unchanged.
The stored apply receipt includes lock_started_at (sampled after lock
acquisition, before authorization) and lock_ended_at (the final receipt-write
marker immediately before commit, rather than a post-commit observation).
Receipt replays preserve these times. The 64 MiB limit and all-or-nothing apply
are unchanged.
The response must be a valid v1 bundle with a matching content digest. Errors expose no remote error text, transport detail, or credential material. A source 413 may supply only validated numeric size and limit fields for the refusal.
Private-address targets are intentionally allowed because company TIER servers
may be internal. This leaves an admin-only SSRF residual: a root principal can
cause an HTTPS request to an internal, loopback, or link-local address reachable
from this server, including a hostname resolving to such an address. DNS rebinding
and server-side network reachability are not restricted by an address denylist.
Network egress policy can constrain that reach. The export credential is sent to
the root-selected host; a wrong host receives a live key that may be able to pull
the named bundle from the real source before consumption or expiry. The source
URL must therefore identify the intended source server. The fetch honours proxy
environment variables via http.ProxyFromEnvironment (code:
internal/api/graft.go, fetchImportBundle; internal/store/graft_source.go,
graftExportIdentity). The export credential is not persisted or forwarded on redirects.
v0.7.0 organizations: child upstream SSRF residual
The child upstream administration rules put the central origin in host configuration (HTTPS by default, or HTTP if the organization allows it in Settings). The central address on the Child server settings page is display-only. Only root can invoke Test connection; it reads central status. The transport contract describes credential storage, TLS checks, request bounds and refusal to follow redirects.
A residual remains: the client does not block private, loopback or link-local
addresses, and it does not pin the hostname to an IP address. The configured
origin can resolve to an internal service, including after a DNS change. The
child can open a TCP connection there even if TLS later fails. Sending the
bearer or report body requires a TLS certificate valid for the configured
hostname under the host's trust roots. Root can trigger a bearer-authenticated
GET /api/v1/federation/status there. The writable child's scheduled sender can
send the bearer and monthly person/model snapshot to /api/v1/federation/push there. Checking
the returned status or body happens after the service receives the request.
This permits requests from the child to an internal HTTPS service at the host-chosen origin. It does not let a browser user choose another destination or an arbitrary path. No redirect is followed. Treat the host's destination and DNS configuration as part of this trust boundary. The credential scope map and reporting trust limits cover who can act inside an organization and what its totals establish.