Skip to content

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-reviewed field, 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 via HttpOnly Secure SameSite=Strict cookies; 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 gosec and govulncheck; both MUST upload SARIF and MUST fail the build on high-severity findings.
  • A secret-scan gate MUST run on every PR. gitleaks is 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-build target; the default build MUST NOT silently link the FIPS module.
  • Dependency currency MUST be tracked with go list -u -m all and 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:

  1. Library quality over novelty. age, golang-jwt/jwt/v5, and gosec are the most widely audited tools in their niches. Selecting the most-audited option reduces the marginal threat-model surface.
  2. 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.
  3. 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.
  4. 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 the age package directly.
  • The wrapper MUST expose at least four constructors:
  • NewEncryptor(secretKey string) — parse an AGE-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) and Decrypt(ciphertext []byte) ([]byte, error). Both methods MUST wrap errors with fmt.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 []byte blob 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/Decrypt signature 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's key_id rather than always using the "current" key.
  • Writes MUST stamp key_id to the current key's identifier and algorithm to the current algorithm. Insert and update statements MUST set all three columns; setting only ciphertext is a defect-class error.
  • The algorithm column allows graceful migration when the underlying primitive is replaced (for example, moving to a post-quantum primitive). Reader code SHOULD switch algorithm to 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 updates key_id, algorithm, and ciphertext in a single transaction. The job MUST be resumable: a WHERE key_id = '<old>' LIMIT N pattern 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 none algorithm 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, and SameSite=Strict (or SameSite=Lax for 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), and exp (expiry) claims MUST be validated on every request. The nbf (not-before) claim SHOULD be validated where revocation requires a re-issue.
  • Claim payloads MUST include sub, org_id, roles (string slice), and a sid (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_id scoping MUST be enforced at the interceptor boundary: the request payload's org_id (or the equivalent tenant key) MUST match the claims' org_id. Cross-tenant requests MUST be rejected with connect.CodePermissionDenied.
  • Handlers MUST receive a typed identity from the context (for example, an auth.Caller struct) 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

  • gosec MUST run on every PR. The recommended invocation is gosec -fmt sarif -out gosec.sarif ./...; CI MUST upload the SARIF to the platform code-scanning sink (GitHub code scanning, GitLab SAST report, or equivalent).
  • gosec MUST 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.
  • govulncheck MUST run on every PR with govulncheck -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 gitleaks with 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.toml allow-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/go toolchain with GOEXPERIMENT=boringcrypto and a -tags fips build tag.
  • The Makefile MUST expose make fips-build distinctly from make 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 /about or /local/infrastructure endpoint so an operator can confirm at runtime which toolchain produced the binary. Stamping buildinfo.FIPSMode = "true" via -ldflags is the canonical pattern.
  • FIPS images MUST be tagged with a -fips suffix 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 all MUST be a Makefile target (conventionally make 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: none JWT tokens. Always pass an explicit WithValidMethods([]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 HttpOnly cookies 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_id scoping at the interceptor. Defense-in-depth requires the interceptor to enforce tenant boundary; handlers MUST NOT be the only place this is checked.
  • Suppressing gosec findings with global // #nosec blanket comments. Suppressions MUST cite the rule id and the reasoning and MUST be reviewed; blanket comments are equivalent to disabling the linter.
  • Manually triaging govulncheck reports 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-build separate.
  • 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.
  • age specification.
  • gosec documentation — rule catalogue and SARIF output.
  • govulncheck documentation — toolchain-pinned scan accuracy.
  • gitleaks documentation.
  • 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.mdmake fips-build target 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.