Security¶
Security is the property that the system protects its assets — data, credentials, control-plane authority, and operator trust — against realistic adversaries. This chapter prescribes the cross-cutting security controls every Go service in this slate MUST implement. It is not a substitute for a threat model; each service MUST author one, review it on the same cadence as the
last-reviewedfield, and store it next to the service's ADRs.
TL;DR¶
- Sensitive column values MUST be encrypted at rest using age (X25519 + ChaCha20-Poly1305) through a thin wrapper package; the master key MUST come from a file or environment variable, never from source.
- Every encrypted column row SHOULD carry a
(key_id, algorithm, ciphertext)triple so keys can be rotated without a downtime migration. - Authentication MUST use
github.com/golang-jwt/jwt/v5. Short-lived access tokens MUST be paired with rotating refresh tokens; browsers MUST receive tokens viaHttpOnlySecureSameSite=Strictcookies; programmatic clients MUST use bearer headers. - Authorisation MUST be enforced by a Connect-RPC interceptor that
reads claims and gates by role and
org_id. Handlers MUST NOT re-implement role checks. - CI MUST run
gosecandgovulncheck; both MUST upload SARIF and MUST fail the build on high-severity findings. - A secret-scan gate MUST run on every PR.
gitleaksis the canonical choice; commit history MUST be scanned, not just the working tree. - FIPS mode MUST be available as a build-tag toggle. The opt-in path
MUST be a separate
make fips-buildtarget; the default build MUST NOT silently link the FIPS module. - Dependency currency MUST be tracked with
go list -u -m alland Dependabot or Renovate; security-relevant dependencies MUST be bumped within the SLA the service's threat model defines.
Why this choice¶
The slate balances four forces:
- Library quality over novelty.
age,golang-jwt/jwt/v5, andgosecare the most widely audited tools in their niches. Selecting the most-audited option reduces the marginal threat-model surface. - Composability with the rest of this guide. Every prescription
below composes with the data, surface, observability, and testing
chapters: encrypted-column types live behind sqlc query interfaces;
RBAC sits in Connect-RPC interceptors; gosec and govulncheck run in
the same CI surface as
make lint. - Operational reversibility. Every control prescribed below MUST
be reversible without a downtime migration. The
(key_id, algorithm, ciphertext)row shape, the refresh-token rotation pattern, and the FIPS build-tag toggle each preserve operational reversibility. - Defense in depth. No single control is sufficient. Authentication alone does not stop SQL injection; authorisation alone does not stop secret leakage; encryption alone does not stop a stolen token. The chapter prescribes all the controls together.
External anchors:
- OWASP Top Ten — the gap analysis every service MUST perform alongside its threat model.
- OWASP ASVS — the verification standard whose Level 2 every multi-tenant service SHOULD meet.
- NIST SP 800-57
— key management lifecycle; the
(key_id, algorithm, ciphertext)shape below derives from this. - RFC 8725 — JWT Best Current Practices — algorithm allow-listing, audience validation, replay protection.
Prescriptive¶
Column encryption with age¶
- A wrapper package (conventionally
pkg/crypto) MUST own all encryption and decryption. Handlers and query code MUST NOT call theagepackage directly. - The wrapper MUST expose at least four constructors:
NewEncryptor(secretKey string)— parse anAGE-SECRET-KEY-1...X25519 identity from a string.NewEncryptorFromFile(path)— read the key from a file.NewEncryptorFromEnv(envVar)— read the key from an environment variable.GenerateKey()— generate a fresh X25519 identity for first-time setup.- The wrapper MUST expose
Encrypt(plaintext []byte) ([]byte, error)andDecrypt(ciphertext []byte) ([]byte, error). Both methods MUST wrap errors withfmt.Errorf("... : %w", err)so caller-side triage can identify the failing stage. - The master key MUST come from a file or environment variable. Hardcoding the master key in source MUST NOT happen. Storing the master key in the same database the data lives in MUST NOT happen (defeats the purpose of at-rest encryption).
- The encryption ciphertext format MUST be opaque to callers. Callers
MUST treat the ciphertext as a
[]byteblob and MUST NOT attempt to parse the age envelope themselves. - Where the production deployment uses a KMS (AWS KMS, GCP KMS, Azure
Key Vault, HashiCorp Vault) for the master key, the wrapper MAY be
extended with a KMS-backed constructor; the wrapper's
Encrypt/Decryptsignature MUST NOT change to accommodate it.
Encrypted-column row shape for key rotation¶
- Every encrypted column SHOULD store its ciphertext alongside two
sibling columns that capture the key identifier and the algorithm
used to produce the ciphertext. The canonical shape is the triple
(key_id, algorithm, ciphertext):
| Column | Type | Meaning |
|---|---|---|
key_id |
text |
Stable identifier for the master key used to encrypt this row's value. |
algorithm |
text |
Algorithm identifier (for example, age-x25519-chacha20poly1305). |
ciphertext |
bytea |
The opaque ciphertext blob returned by Encryptor.Encrypt. |
The three columns MAY be embedded in a single JSONB column when the schema MUST keep the encrypted payload as one logical field; the triple semantics MUST be preserved either way.
- Reads MUST select the decryption key by
key_id. A service MAY hold multiple keys in memory simultaneously; the decryption call MUST look up the key by the row'skey_idrather than always using the "current" key. - Writes MUST stamp
key_idto the current key's identifier andalgorithmto the current algorithm. Insert and update statements MUST set all three columns; setting onlyciphertextis a defect-class error. - The
algorithmcolumn allows graceful migration when the underlying primitive is replaced (for example, moving to a post-quantum primitive). Reader code SHOULDswitch algorithmto select the decryption path; an unknown algorithm MUST return a typed error rather than crash.
The diagram below illustrates the encrypted-column row shape and how rotation works:
flowchart LR
subgraph Write Path
A[Application] -->|Encrypt with current key| B[Encryptor]
B -->|ciphertext| C[(Row)]
D[(Current key_id)] --> C
E[(Current algorithm)] --> C
end
subgraph Read Path
F[Application] -->|Look up by key_id| G{Key Cache}
G -->|Identity| H[Encryptor]
C -->|ciphertext| H
H -->|plaintext| F
end
subgraph Rotation Job
I[Rotator] -->|Read with old key_id| C
I -->|Re-encrypt with new key| C
I -->|Update key_id + algorithm| C
end
Key rotation pattern¶
- New writes MUST use the current key's identifier. The application MUST expose the current key id as configuration (an environment variable, a Kubernetes ConfigMap mount, or a managed config service value) so it can be changed without a code release.
- Readers MUST select the decryption key by the row's
key_id. The application MUST hold every active key (current and any previous keys still referenced by un-rotated rows) in memory. - Rotation MUST be a background job that reads each row with the old
key_id, decrypts with the old key, re-encrypts with the new key, and updateskey_id,algorithm, andciphertextin a single transaction. The job MUST be resumable: aWHERE key_id = '<old>' LIMIT Npattern with an idempotent update is sufficient. - The previous key MUST remain in memory until no rows reference it.
The rotation job's final action MUST be a verification query
(
SELECT COUNT(*) WHERE key_id = '<old>'MUST return zero) before the previous key may be retired from the configuration. - Key generation MUST happen on a separate, audited host. Production master keys MUST NOT be generated on developer workstations.
JWT authentication¶
- Token signing and verification MUST use
github.com/golang-jwt/jwt/v5. The v5 series is the maintained line; earlier versions are end-of-life and MUST NOT be used in new services. - The allowed signing algorithm list MUST be set explicitly when
parsing tokens. Accepting the
nonealgorithm or accepting any algorithm not on an allow list MUST NOT happen — this is the category of bug RFC 8725 calls out. - Access tokens MUST be short-lived (5–15 minutes). Refresh tokens MUST be long-lived (days to weeks) and MUST rotate on use: every use of a refresh token MUST issue a new refresh token AND invalidate the prior one. Refresh-token reuse MUST be treated as a compromise signal and MUST trigger session revocation.
- Browsers MUST receive tokens via cookies with the
HttpOnly,Secure, andSameSite=Strict(orSameSite=Laxfor OAuth callback flows) attributes set. Local storage and session storage MUST NOT hold tokens. - Programmatic clients MUST use the
Authorization: Bearer <token>header. The server MUST accept both cookie and bearer-header transports, with the cookie path taking precedence when both are present so a same-origin browser request cannot be confused with a cross-origin programmatic call. - The
aud(audience),iss(issuer), andexp(expiry) claims MUST be validated on every request. Thenbf(not-before) claim SHOULD be validated where revocation requires a re-issue. - Claim payloads MUST include
sub,org_id,roles(string slice), and asid(session identifier) suitable for server-side revocation lookups.
RBAC interceptor pattern¶
- Authorisation MUST be enforced by a Connect-RPC interceptor that
runs after the authentication interceptor and before any handler.
The interceptor MUST read claims from the request context and gate
by role and
org_id. - The interceptor MUST consult a static route-to-role map keyed by
the fully-qualified RPC method name. Handlers MUST NOT re-implement
role checks; a misconfigured map is easier to detect than scattered
per-handler
if user.Role != ...blocks. org_idscoping MUST be enforced at the interceptor boundary: the request payload'sorg_id(or the equivalent tenant key) MUST match the claims'org_id. Cross-tenant requests MUST be rejected withconnect.CodePermissionDenied.- Handlers MUST receive a typed identity from the context (for
example, an
auth.Callerstruct) rather than re-parsing the JWT. Re-parsing the token in handler code reintroduces every authentication defect class the interceptor exists to centralise. - The interceptor MUST emit a structured audit log line for every
denied request including the
sub, the requested method, the required role, and the actual roles on the token. Auditability is not optional.
gosec + govulncheck¶
gosecMUST run on every PR. The recommended invocation isgosec -fmt sarif -out gosec.sarif ./...; CI MUST upload the SARIF to the platform code-scanning sink (GitHub code scanning, GitLab SAST report, or equivalent).gosecMUST fail the build on HIGH severity findings. MEDIUM severity SHOULD fail the build; the team MAY downgrade to a warning only after authoring a written exception listing the rule and the rationale.govulncheckMUST run on every PR withgovulncheck -format sarif ./.... Any CRITICAL or HIGH severity match MUST fail the build.- Both tools MUST run on the same Go version the service ships with;
govulncheck's scan accuracy depends on the toolchain version. - Findings MUST be triaged within a written SLA. Suppressions MUST cite the SARIF rule id and the reasoning; blanket suppressions MUST NOT happen.
Secret-scan CI gate¶
- Every PR MUST be scanned for secrets before the merge gate. The
canonical tool is
gitleakswith the default ruleset. - The scan MUST cover the full commit history of the branch under review, not just the working tree. A secret committed and reverted in the same branch MUST still trip the gate.
- Allow-list entries MUST be code-reviewed. A
.gitleaks.tomlallow-list line MUST cite the spec ID or ticket authorising it. - A failed scan MUST block merge. The reviewer MUST rotate the leaked credential before approving any remediation PR.
FIPS mode build tags¶
- FIPS-validated crypto MUST be available as a separate build target.
The canonical path on Go is the Microsoft
microsoft/gotoolchain withGOEXPERIMENT=boringcryptoand a-tags fipsbuild tag. - The Makefile MUST expose
make fips-builddistinctly frommake build. Default builds MUST NOT silently link the FIPS module because the FIPS module imposes runtime constraints (algorithm allow list, key length minima) that a non-FIPS workload MUST NOT inherit by accident. - A binary built with FIPS MUST expose its FIPS status on a
/aboutor/local/infrastructureendpoint so an operator can confirm at runtime which toolchain produced the binary. Stampingbuildinfo.FIPSMode = "true"via-ldflagsis the canonical pattern. - FIPS images MUST be tagged with a
-fipssuffix in the container registry. Cluster admission policy MUST be able to require FIPS images in regulated namespaces.
Stay-current-with-deps practice¶
go list -u -m allMUST be a Makefile target (conventionallymake updates) so engineers can see pending upgrades in one command.- Dependabot or Renovate MUST be configured on the repository. Security-relevant dependencies (TLS, crypto, JWT, web frameworks, database drivers) MUST be configured to open PRs on every patch release; non-security dependencies SHOULD open on minor or major releases at a cadence the team can absorb.
- The team MUST review and merge security-dependency PRs within the service's SLA (a common baseline is 7 days for HIGH-severity CVEs, 30 days for MEDIUM). The SLA MUST be documented and reviewed.
- Major-version bumps MUST be reviewed by a human; a robot MUST NOT auto-merge a major upgrade.
Reference Implementation: Pioneer
The Pioneer donor codebase implements the column-encryption layer
above in /home/ubuntu/pioneer/pkg/crypto/age.go. The file defines
an Encryptor struct holding an age.Identity and the derived
age.Recipient, with four constructors (NewEncryptor,
NewEncryptorFromFile, NewEncryptorFromEnv, GenerateKey) and
Encrypt / Decrypt methods that wrap errors with fmt.Errorf
("...: %w", err). The ciphertext envelope is opaque to callers,
consistent with the prescription above.
The donor's column encryption uses the age X25519 identity
primitive; rotation in the donor today is a manual operator
procedure with one master key. Newer projects adopting this guide
SHOULD additionally persist the (key_id, algorithm, ciphertext)
triple from day one so the rotation pattern is available before it
is needed. The donor's pkg/crypto/age.go is the wrapper-package
shape to mirror; the row-shape addition is a forward-going
enhancement.
Pinned versions¶
| Component | Version pinned | Rationale |
|---|---|---|
filippo.io/age |
v1.3.1 | Latest stable; X25519 + ChaCha20-Poly1305 envelopes. |
github.com/golang-jwt/jwt/v5 |
v5.3.1 | v5 line; v4 receives security fixes only. |
github.com/securego/gosec/v2 |
v2.21.x | SARIF output supported; ruleset stable. |
golang.org/x/vuln (govulncheck) |
latest stable per Go release | Tied to Go toolchain; bump in lockstep with Go. |
gitleaks |
v8.x | Default ruleset; SARIF output supported. |
microsoft/go (FIPS toolchain) |
latest stable for the target Go minor | GOEXPERIMENT=boringcrypto lineage. |
Pitfalls¶
- One master key for every row. A single key forecloses rotation.
SHOULD adopt the
(key_id, algorithm, ciphertext)triple from day one so rotation is a configuration change rather than a migration. - Accepting
alg: noneJWT tokens. Always pass an explicitWithValidMethods([]string{"HS256", "RS256"})(or the algorithm list the deployment uses) when parsing. Never accept the algorithm field on faith. - Storing tokens in localStorage. Localstorage is reachable by
any script in the page. Use
HttpOnlycookies for browsers. - Re-implementing role checks in handlers. A handler-side
if role !=block is invisible to the route-to-role map and to the audit log. SHOULD centralise authorisation in the interceptor. - Skipping
org_idscoping at the interceptor. Defense-in-depth requires the interceptor to enforce tenant boundary; handlers MUST NOT be the only place this is checked. - Suppressing
gosecfindings with global// #nosecblanket comments. Suppressions MUST cite the rule id and the reasoning and MUST be reviewed; blanket comments are equivalent to disabling the linter. - Manually triaging
govulncheckreports without a re-run on the shipped toolchain. A vuln-check result is only valid for the toolchain that ran it. Re-run on the shipping Go version, not on whatever version happened to be on a developer laptop. - FIPS mode silently linked into default builds. Default
binaries MUST NOT carry FIPS constraints; the FIPS module imposes
algorithm restrictions that surprise unrelated workloads. Keep
make fips-buildseparate. - Refresh-token reuse without revocation. A reused refresh token is a compromise indicator. Detection MUST trigger session invalidation, not a silent retry.
- Allow-listing the same secret regex repeatedly in
gitleaks. Allow-list entries accumulate; the team SHOULD review the allow-list quarterly and remove entries whose justification has expired.
See also¶
- RFC 2119 keywords — every MUST/SHOULD/MAY in this chapter follows the canonical definitions.
- OWASP Top Ten and OWASP ASVS — gap analysis and verification standard.
- NIST SP 800-57 — key management lifecycle.
- RFC 8725 — JWT Best Current Practices.
agespecification.gosecdocumentation — rule catalogue and SARIF output.govulncheckdocumentation — toolchain-pinned scan accuracy.gitleaksdocumentation.- Chapter
02-data.md— sqlc query interfaces and JSONB column patterns the encrypted-column shape composes with. - Chapter
03-surface.md— Connect-RPC interceptor wiring. - Chapter
04-infra-tooling.md—make fips-buildtarget and multi-stage Dockerfile composition. - Chapter
05-observability.md— structured audit logs and correlation IDs feed the audit trail this chapter relies on. - Future ADRs — key-rotation cadence and KMS choice are candidate ADRs; FIPS-mode policy is a candidate ADR.