Customers sign their own tokens. The backend holds each organization's public key and verifies signatures; it never issues tokens. There are no sessions, API keys, or OAuth. Endpoint scopes per route: HTTP API.
| Surface | Transport |
|---|---|
| HTTP API | Authorization: Bearer <jwt> |
| Git smart HTTP | Basic auth in remote URL: https://t:<jwt>@{org}.code.storage/{repo_id}.git (username is the literal t, password is the JWT) |
| Fork-source auth | base_repo.auth.token in create-repo body |
Verification accepts these algorithms, keyed by the per-org public key:
| Algorithm | Notes |
|---|---|
| ES256 | Default in all SDKs; P-256 ECDSA; PKCS#8 or SEC1 private keys client-side |
| RS256 | Python SDK auto-selects for RSA keys |
| EdDSA | Python SDK auto-selects for Ed25519/Ed448 |
| PS256, ES384, ES512 | Auto-detected by the vendor's integration-test helper; accepted for maximal compatibility |
Official docs list only ES256 and RS256 (EC P-256/384/521 and RSA keys).
JWT header: {"alg": "<alg>", "typ": "JWT"}.
A token is a compact JWS: three segments (header, payload, signature) separated by ..
- Each segment must be canonical unpadded base64url (RFC 7515): no
=padding, no+or/, no spaces or line breaks, and unused trailing bits set to zero. Any other spelling fails verification (see Failure mapping), even when it decodes to the same bytes. - The rule applies to the token itself. The HTTP API trims spaces around the
Authorizationvalue; the Git Basic-auth password is checked as sent. - Standard JWT libraries emit this form, including the ones the SDKs use (
jose, PyJWT, golang-jwt). Earlier versions accepted other spellings; a hand-built token that relies on one must be reissued. - Canonical encoding does not provide replay protection or make token text a unique credential identity. For example, an ECDSA signature has a second valid form with different bytes.
RSA organization public keys need a modulus of at least 2048 bits, for both RS256 and PS256. ECDSA (P-256/384/521) and Ed25519 are unaffected.
- Registration: only through single-org boot configuration (
OPENSTORAGE_ORG_PUBKEYS). There is no HTTP org-key registration endpoint. - Startup rejects a weak RSA key, reporting the file path and required minimum.
- Every request checks every registered PEM block, including keys stored before the upgrade.
- One weak key rejects the whole set: even a token signed by a strong key fails verification (see Failure mapping), not 500. Weak keys are never silently skipped, and there is no override.
- Scope: public authentication keys only. Client-side private-key parsing and token-minting helpers are unchanged, so a successfully minted token does not mean the server accepts its key.
Warning
Rotate before upgrading. On the old fleet, add a strong key, switch clients to it, and reissue tokens (including Git credentials). Then remove every weak key from the PEM files on all replicas and from registered org key sets before rolling out. Updating only the database is not enough: startup replaces the configured org's registered keys from its file. Old weak-key tokens stop working. Adding a strong key without removing the weak one is not enough either. See fleet key rotation.
{
"iss": "<org name>", // must match the org of the request host
"sub": "<any non-empty string>", // caller label, e.g. "pierre-sdk-go"
"repo": "<repo url|id>" | "org", // target repo; "org" or omitted for org-level operations
"scopes": ["git:read", "git:write"], // see scope table
"iat": 1700000000, // seconds
"exp": 1700003600, // seconds; SDK defaults: 1h per API call,
// 365d for generated git remote URLs
"refs": [["refs/heads/main", ["no-force-push"]], // OPTIONAL ordered ref policies
["refs/heads/feature/*", []]],
"ops": ["no-force-push"] // OPTIONAL, deprecated repo-wide policy ops
}repocarries the repository url value (whatGET /api/repo-urls/{repo_id}returns).- A token with no
repoclaim is org-wide and behaves exactly likerepo: "org": org-scoped endpoints accept it, and repository endpoints and the git surface refuse it with403. Arepothat is present but empty (""ornull) is not org-wide: every endpoint refuses it with403. subis a free-text label that the signer picks ("Agent identity (for logging)" in the upstream guide). Any non-empty string verifies, and a token with an empty or missingsubfails verification. It grants nothing, and open.storage does not log it or key anything on it. SDK 1.17.1 andopenstorage mintsend@pierre/storage. SDK 0.1.0 sendspierre-sdk-go,pierre-sdk-pythonorpierre-sdk-typescript, and omitsrepofor org-wide calls.- A token verifies when: the signature is valid for the org,
expis in the future,subis not empty,repomatches the target repo (or is"org"or omitted for org-scoped endpoints), and the required scopes are present. - SDKs mint a fresh JWT per request when configured with a private key, and send pre-minted tokens verbatim. The backend must not assume token reuse or caching.
| Scope | Grants |
|---|---|
git:read |
all read endpoints (files, branches, tags, commits, diffs, blame, grep, notes read, merge preview, GET /repo metadata) + git clone/fetch |
git:write |
ref mutations (commit-pack, diff-commit, restore-commit, branch create/delete, merge, tag create, notes write/append/delete, pull-upstream and upstream/pull) + git push |
repo:write |
repo lifecycle (create/delete repo, git-credentials CRUD, unset base repo via DELETE .../base or DELETE .../upstream) |
org:read |
organization metadata (GET /api/meta, repo: "org" or no repo) and org-wide listing (GET /repos, GET /api/repo-urls/{repo_id}) |
Endpoint quirks (verified across all three SDKs):
DELETE /repos/tagsis requested with bothgit:readandgit:write.createRepomintsrepo:writewithrepo= the new repo id (client-generated UUID if unspecified).listRepos,updateGitCredential,deleteGitCredentialuserepo: "org";createGitCredentialusesrepo: <repoId>. SDK 0.1.0 omitsrepofor these org-wide calls instead.
Encoding: a JSON array of [pattern, ops] 2-element tuples. Evaluation:
- Normalize shorthand in both the target ref and rule patterns: anything not starting
with
refs/(and not exactly*orHEAD) becomesrefs/heads/<value>(main→refs/heads/main,agents/*→refs/heads/agents/*). - Scan rules in order; first pattern match wins (an exact ref, a lone
*, or a single trailing/*prefix such asrefs/heads/*; seeopenstorage mint). - Apply that rule's ops; an empty ops list means "allow everything".
| Op | Enforcement |
|---|---|
no-push |
reject any update to matching refs |
no-force-push |
reject non-fast-forward updates (git push -f, restore-commit that rewinds, etc.) |
verify-sig |
reject pushes introducing commits without a valid signature from a registered key |
Ops apply to git receive-pack and every HTTP write endpoint. They are the only branch-protection mechanism; there is no server-side protection config.
Policy checks read each object as itself: they run git with
GIT_NO_REPLACE_OBJECTS=1, so a refs/replace/* ref, which any token with
git:write can push, changes neither the ancestry that no-force-push reads nor the
commits and signatures that verify-sig reads.
| Condition | Status |
|---|---|
REST API: no Authorization header, or a scheme other than Bearer |
401 unauthorized (Authentication required) |
Git transport: no Basic auth with the user t. LFS transport: neither that nor an Authorization: Bearer token |
401 |
| the token fails verification (malformed, bad signature, expired, unknown issuer) | REST API: 403 forbidden (Invalid or expired token), as upstream answers. Git and LFS transports: 401, so that git asks for credentials (deviation) |
| a verified token issued for another organization | 403 forbidden; the REST detail is Credential is not valid for this tenant |
| the key store fails while the server verifies the token | REST API, fork source tokens included: 403 authentication_unavailable (retry). Git and LFS transports: 401, as for an invalid token |
| valid token, insufficient scope or ref policy denies | 403 |
repo claim mismatch with target |
403 (do not leak existence: 404 acceptable per vendor error-string instability) |
Error body: {"error": "<message>"} plus the problem-details fields described under
HTTP API conventions (type, title, status, a generic
code such as unauthorized or forbidden, and detail), on every route, the
ref-moving write endpoints included. Clients branch on status codes (or code), not
messages.