M1-44: API tokens with prefix and checksum¶
Status: ready | Depends on: M1-29, M1-35 | Milestone: M1
Goal¶
After this ticket agents, apps and (behind the switch tokens.personal_enabled) people
authenticate with long-lived API tokens of the form bothy_<kind>_<random>_<checksum>: stored only
as HMAC, shown exactly once, with a mandatory expiry, DPoP-bound for agents by default, revocable
with immediate effect, and recognizable by the secret scanner. ADR 0061 records the format. A
personal token acts with the person's grants but never with owner permissions and never
provides step-up (SR5).
Read first¶
docs/spec/08-identity.md: 7.1 "Overview", 7.3 "DPoP", 12 "API tokens for agents, apps and tools", 15 (ID-T23, ID-T27), 18 (auth.dpop_required_for,auth.api_token_max_ttl,auth.personal_token_max_ttl,auth.token_pepper_ref).docs/spec/03-data-model.md: 1 "Principles" (point 9), 4.2api_tokens,principals, 4.10idempotency_keys, 4.12audit_events, 6.3 "Lock order".docs/spec/05-actions.mdanddocs/spec/actions.yaml:token.list,token.create,token.revoke; 2.1, 2.3 point 1, 2.4, 2.5, 2.6.docs/spec/07-permissions.md: 3 "Principal context", 4.1 (token_revoked,step_up_not_possible,owner_only,not_self), 4.2, SR5, SR7, 11.1 AZ-M35.docs/spec/06-api.md: 3 "Authentication per principal kind", 3.1 "DPoP", 5 "Errors" (feature_disabled), 6 "Pagination", 7 "Idempotency".docs/spec/16-operations.md: 6.2 "Feature flags".docs/spec/19-security.md: 9, 10 (SEC-SEC-3, SEC-SEC-4).docs/spec/02-architecture.md: 4.3 (identityportTokens), 4.4, 4.5 (kernel/actor,kernel/idempotency), 5, 9.- ADRs 0000 (template), 0001, 0015, 0017, 0020, 0042, 0044.
docs/api/openapi.yaml: operationslistTokens,createToken,revokeToken; schemasApiToken,ApiTokenCreate,ApiTokenCreated,ApiTokenPage..gitleaks.tomlrulebothy-api-token;tools/ci/tests/test_gitleaks_rules.py.docs/security/threat-model.md: T-TK-02.- Merged READMEs and code:
internal/identity(authenticator, HMAC hasher with the pepper, DPoP verifier of M1-33 and M1-34),internal/authz(guards, permission-change path of M1-29),internal/kernel/idempotencyand its middleware (M1-20),internal/instance(portFlags, if M1-47 is merged),services/server/api/operations.yaml. docs/PLAN.mdsection 3, section 7 rows M1-34, M1-44, M1-45, M1-47, M1-51;docs/quality/gates.md;CONTRIBUTING.md.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every behavior change has a test that was red before the change (the red run is quoted in the report), then green, then refactor.
- No en dash or em dash characters in any file you write; use a hyphen or rephrase.
- White label; every committed token-shaped value carries the marker
EXAMPLEin its random part (CONTRIBUTING.md), all others are built at runtime. - Limits only through named configuration keys (
auth.api_token_max_ttl,auth.personal_token_max_ttl,auth.dpop_required_for,identity.last_active_granularity). - Format
bothy_<kind>_<random>_<checksum>with kindsagt,app,pat,whi, 256 bits of randomness in Base62 and a checksum over the random part (08 12, ADR 0044 for the prefix). - Stored only as HMAC-SHA-256 with the server pepper plus
token_prefix; shown exactly once (08 12, 03 point 9, ID-T23). - Expiry is mandatory, at most
auth.api_token_max_ttl, personal tokens at mostauth.personal_token_max_ttl(08 12). - Agent tokens are DPoP-bound when
agentis inauth.dpop_required_for; a DPoP-bound token asBeareris rejected (08 12, 7.3, RFC 9449 7.2). - API tokens never provide step-up; a personal token of an owner has no owner permissions (SR5, 05 2.3 point 1, AZ-M35).
- Personal tokens only with
tokens.personal_enabled; switched off gives403 feature_disabled(05token.create, 06 5 point 5). - The secret scanner has a rule for the prefix (SEC-SEC-4).
Scope¶
services/server/internal/identity/domain/apitoken/: pure format code: generate, parse, checksum, prefix; kindsagt,app,pat,whi; the constantbothyexists exactly once.testdata/checksum_vectors.jsonwith vectors whose random part starts withEXAMPLE.services/server/internal/identity/portTokens(02 4.3):Issue(ctx, IssueTokenCmd) (IssuedToken, error),List(ctx, principalID uuid.UUID, page Page) (TokenPage, error),Revoke(ctx, tokenID uuid.UUID, by uuid.UUID) error,RevokeAllForPrincipal(ctx, principalID uuid.UUID, by uuid.UUID) (int, error); all join the ambient Unit of Work.IssuedTokencarries the secret only in a type whoseString,FormatandMarshalJSONprint[redacted]except through one explicit accessor used by the HTTP handler (SEC-LOG-1).services/server/internal/identity/app/: use casesCreateToken,ListTokens,RevokeToken; consumer portPersonalTokenSwitchwithPersonalTokensEnabled(ctx context.Context, principalID uuid.UUID) (bool, error); API token authentication used by the authenticator.services/server/internal/identity/adapters/postgres/: migration forapi_tokensexactly as in 03 (next free goose number), sqlc queries.services/server/internal/identity/adapters/httpapi/: handlerslistTokens,createToken,revokeToken.- The
kernel/actor.Authenticatorimplementation of M1-34: the API token path (Design notes); export the functionAuthenticateAPITokenfor the gatewayidentifyof M1-64. services/server/internal/authz/: guard fortoken.create(a token for a person only for oneself,forbiddennot_self) in the guard table ofauthorize(07 4.2 step 9), with tests.- One-time secret: the
createTokenhandler callsidempotency.MarkOneTimeSecret(ctx)of M1-20 (Design notes). internal/wire: bindings, includingPersonalTokenSwitch(Design notes)..gitleaks.tomlrulebothy-api-tokenalready requires exactly 6 checksum characters;tools/ci/tests/test_gitleaks_rules.py: add a runtime token with a valid checksum and a cross-check against the Go vectors.- The specification is in place (planning, spec first): ADR 0061 (format, Base62 alphabet,
lengths, CRC-32,
token_prefixwith 6 characters), 08 12 (kinds per principal, scopes rule, personal tokens only for oneself,app_not_installed, one-time secret), 03api_tokens(expires_atNOT NULL), 06 7 (R-API-26),openapi.yaml(ApiTokenCreate.expires_atrequired, description ofcreateToken). Change them only where the implementation proves them wrong (docs/spec/02-architecture.md4.3 if the port names differ).services/server/api/operations.yaml: the three operations move to the built list; regenerated server code. services/server/internal/identity/README.md, CHANGELOG entry under "Unreleased" ofservices/server/CHANGELOG.md,docs/DECISIONS.mdentries for the rulings below,docs/security/threat-model.mdT-TK-02 test column with the test names.authz.CredentialSource, API tokens part (02 "Ports, events and sources per module", 07 "Flow"check_active, PLAN row M1-44): implementTokenActiveandTokenExpiresAtof the request's API token in the identity adapter of the source port that M1-28 declared, and replace the corresponding empty source ininternal/wire(fail closed until then). Test:TestCredentialSource_Tokensagainst PostgreSQL 18 (active, revoked, expired, foreign principal).
Out of scope¶
- Incoming webhook tokens (
whi): format support only; issuance, path handling and verification belong to M2-27. - App tokens issued at installation with installation scopes: M2-24 (see Design notes for the behavior until then).
- Revoking personal tokens on
user.disable: M1-45 (usesRevokeAllForPrincipal). Revoking agent tokens onagent.disable: M1-51. - Closing open gateway connections on revocation (
4012): M1-65. - The feature flag registry and
instance.Flags: M1-47. - Console listing of tokens that expire soon: M6.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M1-44-identity-api-tokens, created from the base branch the launcher names. - Conventional Commits with the module as scope (
feat(identity): ...,docs(adr): ...) and a DCO sign-off on every commit (git commit -s). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the Scope list, except the docs and changelog entries named in this brief.
- Follow the host rules supplied by the launcher.
- Tests bind only
127.0.0.1and port 0 (P-10). PostgreSQL only throughinternal/platform/pg/pgtest; without a Docker-compatible runtime the test fails, it never skips (P-09). - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets; never commit a token-shaped value without
EXAMPLE. - No network calls except package downloads for pinned tools.
- A gap in the core contract (00, 03, 04, 05,
docs/spec/actions.yaml) ends the ticket withVerdict: BLOCKED(P-11); never build a deviation.
Design notes¶
Preconditions (check before writing code; any failure: Verdict: BLOCKED <which>).
- M1-29 (permission-change path:
permission_versionfirst,access.changed; with M1-24 audit) and M1-35 (step-up fortoken.create) are merged: both are dependencies of this ticket. - The DPoP verifier (M1-33) and the HMAC hasher with
auth.token_pepper_ref(M1-34) exist.
Tests obtain step-up through the M1-35 flow (or its test helper), not by writing columns.
Format (08 12 and ADR 0061):
bothy_+ kind +_+ random +_+ checksum; kindsagt(agent),app(app),pat(personal),whi(incoming webhook). Total length 60 characters.- Random: 32 bytes from
crypto/rand, read as a big-endian unsigned integer, written in Base62 with the alphabet0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz, left-padded with0to 43 characters (62^43 > 2^256). - Checksum: CRC-32 (IEEE,
hash/crc32.ChecksumIEEE) over the 43 ASCII bytes of the random part, written in the same Base62 alphabet, left-padded to 6 characters. Public precedent: CRC32, Base62 with leading-zero padding, last 6 characters (https://github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/, retrieved 2026-10-10) [verified]. The checksum rejects typos and random matches without a database call; it is not a security control. - Parse: exactly
^bothy_(agt|app|pat|whi)_[0-9A-Za-z]{43}_[0-9A-Za-z]{6}$, checksum equal, the random part decodes to a value below 2^256. token_prefix=bothy_<kind>_plus the first 6 characters of the random part.token_hash= HMAC-SHA-256(pepper ofauth.token_pepper_ref, the full token string).
Kinds and principals. agt only for principals of kind agent, app only for app, pat
only for user; whi is never issued by createToken and never accepted by the authenticator.
createToken (POST /api/v1/principals/{principal_id}/tokens, token.create, step-up,
rl.admin, Idempotency-Key):
- Load the principal; missing gives
404. Resource descriptor: kindprincipal, owner = the principal. authorize(actor, token.create, principal); the newauthzguard rejects a token for a person other than the actor withforbiddenreasonnot_self(ruling: a personal token is the person's own credential; an owner minting one for someone else could act as that person).- Kind from the principal kind. For
appprincipals answer409 conflictwithreason: app_not_installeduntil M2-24 replaces this with the installation check (apps cannot exist in M1). - Kind
pat:PersonalTokenSwitch.PersonalTokensEnabled(ctx, actor)false gives403 feature_disabled. - Body:
name1 or more characters without control, format or bidi characters;scopesmust be empty foragt(agents hold their scopes implicitly, 05 2.6) and forpat(ruling: personal tokens act with the person's grants; 07 knows scopes only for apps), else400 validation_failedwith pointer/scopes;expires_atrequired (08 12), later than now and not later than now plusauth.api_token_max_ttl(agt,app) orauth.personal_token_max_ttl(pat), else400with pointer/expires_at;dpop_jktoptional, exactly 43 base64url characters (RFC 7638 SHA-256 thumbprint), required when the kind is inauth.dpop_required_for(valuesagent,app,personal; register the allowed values in the configuration schema if missing), else400with pointer/dpop_jkt. - Generate, insert (
created_by= actor), audit allow with details{token_id, kind, principal_id, dpop_bound, expires_at}(never secret, never hash).token.createchanges no permissions. 201 ApiTokenCreatedwithsecret; headerCache-Control: no-store.
Idempotency with a one-time secret. 03 point 9 forbids storing the token. The handler calls
idempotency.MarkOneTimeSecret(ctx) (M1-20): the stored row holds no part of the secret, and a
replay with the same key and body answers 409 conflict with reason: one_time_secret and
Idempotent-Replay: true (06 7, R-API-26); the owner revokes the unknown token in the list.
listTokens (GET /api/v1/principals/{principal_id}/tokens, token.list, rl.read):
authorize on the principal (owner, or the principal itself); tokens with revoked_at IS NULL
(expired ones included, so they can be seen and revoked), ordered by created_at descending then
id, cursor pagination per 06 6; schema ApiToken, never hash or secret.
revokeToken (DELETE /api/v1/tokens/{token_id}, token.revoke, rl.admin): load the
token (missing: 404); resource kind api_token with owner = principal_id; authorize; already
revoked: 204 without a new audit entry; otherwise in a permission-change Unit of Work (M1-29
path): revoked_at = now, revoked_by = actor, audit allow, access.changed for the token's
principal as the path computes it. 204.
RevokeAllForPrincipal revokes every active token of a principal in the caller's Unit of
Work and returns the count; the caller owns the permission change and the audit (M1-45,
M1-51).
Authentication (shared authenticator, 02 9 step 4):
Authorization: Bearer <t>orAuthorization: DPoP <t>wheretstarts withbothy_takes the API token path; everything else stays on the access-token path of M1-34.- Parse and checksum fail, kind
whi:401 unauthenticatedwithout a database call. - Lookup by
token_hash; missing,revoked_atset orexpires_at <= now:401 unauthenticated(audit reasontoken_revokedfor the last two); principal kind not matching the kind:401. dpop_jktset: scheme must beDPoPand the proof must verify with the M1-33 verifier (htm,htu,iat,jti, nonce,ath= base64url SHA-256 of the token, key thumbprint equal todpop_jkt), else401 invalid_dpop_proofwithWWW-Authenticate: DPoP error="invalid_dpop_proof"(oruse_dpop_noncewith a freshDPoP-Nonce).dpop_jktNULL and schemeDPoP:401 unauthenticated.- Actor:
id= principal,kindfrom the principal,token_id,token_scopes, no session, no device. Status checks of the principal stay inauthz(SR7). No cache of token state: revocation must work on the next request in every process (AZ-P9). last_used_at: after a successful authentication, one conditional updateWHERE last_used_at IS NULL OR last_used_at < now - identity.last_active_granularity; a failure is logged with the token ID and never fails the request.
PersonalTokenSwitch binding. M1-47 (feature flags) depends on this ticket and comes later.
Bind app.PersonalTokensDefault{} in internal/wire, which returns the registered default false
(16 6.2: unknown or unregistered flags are off); M1-47 registers tokens.personal_enabled and
rebinds PersonalTokenSwitch over its Flags port. Tests use a fake switch for both values.
ADR 0061 exists (accepted). Keep it current: if the implementation changes a rule or the
confirmation (TestFormat_*, the checksum vectors in Go and Python, the gitleaks rule test),
update the ADR in the same change and run make adr-index.
Tests (write them first)¶
Unit tests (domain/apitoken):
TestFormat_RoundTripfor every kind; length 60; prefix and kind positions.TestFormat_ChecksumVectors: readstestdata/checksum_vectors.json(random parts starting withEXAMPLE, at least one checksum with leading0padding) and checks every checksum.TestParse_Rejects: wrong prefix, unknown kind, random part of 42 and 44 characters, a non-Base62 character, a single changed checksum character, a random part at or above 2^256.TestGenerate_ReadsExactly32BytesFromCryptoRand(ID-T27, injected reader) andTestTokenPrefix.
Integration tests (PostgreSQL 18, real HTTP chain on 127.0.0.1:0, steerable clock, fake
PersonalTokenSwitch):
TestIDT23_OnlyHashStored: aftercreateTokenthe full secret appears in no row ofapi_tokens,idempotency_keysandaudit_events;token_hashis the HMAC.TestIDT23_ShownOnce:listTokensnever returns the secret; a replay with the sameIdempotency-Keygives409 conflictwithreason: one_time_secret.TestIDT23_WrongChecksumRejectedWithoutLookup(repository fake counts calls),TestIDT23_ExpiredRejected,TestIDT23_RevokedRejected.TestIDT23_DPoPBoundAgentTokenWithoutProof:Bearergives401 invalid_dpop_proof; a proof with another key gives401; a valid proof succeeds onlistTokensfor the agent's own principal.TestCreateToken_AgentWithoutJKT400,TestCreateToken_ExpiryRequiredAndBounded(both maxima),TestCreateToken_ScopesRejectedForAgentAndPersonal,TestCreateToken_AppPrincipal409,TestCreateToken_StepUpRequired(owner session without fresh step-up gives401 step_up_required).TestCreateToken_PersonalDisabled403(feature_disabled) andTestCreateToken_PersonalForOtherPerson403(not_self).TestAZM35_PersonalTokenCannotStepUp: Sam's personal token callingcreateTokenfor Sam gives403with reasonstep_up_not_possible.TestPersonalTokenOfOwnerHasNoOwnerRights: the owner's personal token callingcreateTokenfor an agent gives403with reasonowner_only.TestRevokeToken_Immediate: the next request with the token gives401; a secondDELETEgives204; a token of another principal without permissions gives404;permission_versionraised and audit entry written.TestRevokeAllForPrincipalandTestLastUsedGranularity.TestAPITokenNeverLogged: log and audit capture contain no secret.- Route count (AZ-P1) and response validation pass with the three operations.
Python tests (tools/ci/tests/test_gitleaks_rules.py):
test_bothy_token_with_valid_checksum_is_detected: builds a token at runtime withsecrets.token_bytes(32), Base62 andzlib.crc32, and asserts the rule fires.test_checksum_vectors_match_go: recomputes every vector of the Gotestdatafile in Python.- The existing sample for
bothy-api-tokenkeeps passing with the 6-character checksum.
Commands that must pass:
cd services/server && go test -race ./internal/identity/... ./internal/authz/... ./internal/kernel/...
cd tools && uv run --locked pytest ci/tests/test_gitleaks_rules.py
make gitleaks
make codegen-check
make arch
make api-lint
make adr-index
make docs
make check
Acceptance criteria¶
- Preconditions verified and named in the report.
- ID-T23 green in all its parts; T-TK-02 in the threat model names the tests.
- Format exactly as specified; Go and Python agree on the checksum vectors; the gitleaks rule detects a runtime token and requires 6 checksum characters.
- No secret in the database, the idempotency store, audit, logs or problem bodies.
- Expiry mandatory and bounded; agent tokens DPoP-bound by default;
Bearerwith a bound token rejected. - Personal tokens: switch respected (
403 feature_disabled), only for oneself, no owner permissions, no step-up (AZ-M35). - Revocation effective on the next request;
RevokeAllForPrincipalavailable for M1-45 and M1-51. - The implementation matches ADR 0061, 03, 06, 08 and
openapi.yaml(changed only where proven wrong);operations.yamland DECISIONS updated;make checkgreen.
Definition of done¶
- Code and tests as above; every public symbol has a doc comment (purpose, parameters, return value, errors, example where useful); comments explain why.
internal/identity/README.mdcurrent (token format, portTokens, authentication path); CHANGELOG entry under "Unreleased"; ADR 0061 current;docs/DECISIONS.mdentries.make checkgreen.- Independent review (
make reviewand a code review over the diff) and the red team gate over the diff, verdict recorded in the report; red team item 4 of the M1 section (personal token with owner permissions, step-up with a token without session) applies directly. - Merge locally into the base branch only if review and red team are green and
make checkis green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, the test results with counts (including the red runs), what
is not proven, and follow-ups that could not be done in scope with the reason (at least: the
PersonalTokenSwitch rebinding by M1-47, app tokens in M2-24). Then write {{DONE_PATH}} with exit=0 sha=<sha> or
exit=1 <reason>.