Security

Password storage, breach checks, JWT authentication, encryption, and the operational endpoints to keep private

The security-relevant packages, and the operational defaults worth checking before a service faces the internet.

Password Storage

passwordhash implements Argon2id (RFC 9106) following the OWASP Password Storage Cheat Sheet, with New() returning the RFC 9106 §4 defaults.

p := passwordhash.New()

hash, err := p.PasswordHash(plaintext)      // store this
ok, err := p.PasswordVerify(plaintext, hash) // on login
upgrade, err := p.PasswordNeedsRehash(hash)  // after a successful verify

By default the stored value is a base64-encoded JSON object of about 200 bytes that describes itself: algorithm name, Argon2id version, key and salt lengths, time and memory cost, parallelism, the random salt, and the derived key.

WithFormat(FormatPHC) emits the standard PHC string instead, the format shared by Argon2 implementations across languages:

$argon2id$v=19$m=65536,t=3,p=4$<base64 salt>$<base64 key>

That is what external tooling reads and writes directly (PHP’s password_hash, Python’s argon2-cffi and passlib, the Argon2 reference CLI), where the JSON schema is nurago-specific. Verification auto-detects which of the two a stored value uses, so the choice is never locked in and an argon2id hash minted elsewhere verifies as is. The accepted PHC envelope is strict: argon2id only, version 19 only, cost parameters in the standard m,t,p order, no keyid or data attributes, canonical unpadded base64.

Because the parameters travel with the hash in either format, cost can be raised without invalidating what is already stored. Verification reads the parameters out of the stored value. PasswordNeedsRehash then reports, after a successful verification, that this particular hash was minted with outdated parameters, at the one moment when the plaintext is available to mint a new one. It reports the same for a hash stored in a format outside the accepted set, so a store converges to the configured format through the ordinary rehash-on-login flow, or stays deliberately mixed when both formats are listed as accepted.

For deployments that keep a secret pepper outside the database, EncryptPasswordHash and EncryptPasswordVerify add an AES-GCM layer over the Argon2id hash. An attacker with the database alone then holds ciphertext rather than hashes.

Breach Checks

passwordpwned checks a password against the Have I Been Pwned Pwned Passwords API v3 using the k-anonymity model: only the first 5 hex characters of the SHA-1 hash leave the process. The API returns every suffix sharing that prefix and the full match is resolved locally, so neither the password nor its complete hash is ever sent.

c, err := passwordpwned.New()

pwned, err := c.IsPwnedPassword(ctx, password)

PwnedCount returns the raw breach count instead, for NIST-style threshold policies (“reject only if seen more than N times”).

Requests set the Add-Padding header, so every response carries 800 to 1,000 entries regardless of the real match count and the response size leaks nothing. The decoded body is capped (WithResponseSizeLimit) against decompression-bomb style memory exhaustion, and a 200 response that is not structurally valid range data (a captive-portal page, for instance) is rejected with ErrMalformedResponse rather than being read as “not pwned”. Transient network errors are retried through httpretrier, honouring Retry-After.

Call it at registration and password change. Every call is a network round trip to a third party, which makes it a poor fit for the login path.

JWT Authentication

jwt issues and validates short-lived tokens for username and password login flows, on the Go standard library alone.

// mint on successful login, validate on protected routes
handler := j.Middleware(protected)          // injects verified claims into the context
claims, err := j.Authenticate(bearerToken)  // for custom middleware
token, err := j.IssueToken(...)             // outside the HTTP login flow
claims, err := j.VerifyToken(raw)           // tokens arriving over any transport

Tokens are RFC 7515 compact JWS with RFC 7519 claims, signed with HMAC-SHA2 (HS256, HS384, or HS512). Symmetric HMAC is the only surface, which removes the classic attacks structurally: no asymmetric path exists to confuse with HMAC, and alg=none has no code to reach. The signature is verified before the claims payload is decoded.

A crit header or a duplicated header parameter is rejected. Other unknown JOSE header parameters are ignored. exp and nbf are validated, with optional leeway.

Credential verification is delegated to a caller-supplied VerifyCredentialsFn, so the package is agnostic to how passwords are stored: use passwordhash for that. Defaults are a 5-minute expiration, a 30-second renew window, the Authorization header, and caps on both request body and token size.

There is no revocation list, so a compromised token stays valid until it expires. That is what the short default expiry is for. RenewHandler renews a valid token only when it is close to expiry.

Encryption

encrypt provides AES-GCM authenticated encryption for payloads moving between systems (databases, queues, caches, external services), with a random 96-bit nonce prefixed to the ciphertext. Raw byte APIs are available alongside helpers that serialize a value with gob or JSON before encrypting.

Two constraints come with random nonces and are stated in the package documentation. First, the number of messages safely encrypted under one key is bounded by the birthday paradox: rotate keys well before ~2^32 messages per key, because a nonce collision under one key breaks both confidentiality and authentication. Second, nonce uniqueness depends entirely on the randomness source, which is crypto/rand.Reader; the override exists for tests only.

Prefer the JSON helpers over the gob ones for cross-language or lower-trust payloads. The base64 output uses standard encoding, which is not URL-safe, so re-encode at the call site if the payload goes into a URL or a path.

Log Redaction

Secrets reach logs through request dumps, error strings carrying a DSN, and JSON payloads. redact is applied by default in httpclient, httpserver, and httpreverseproxy, and is covered in /docs/observability/. Its two limits: matching anchors on structure, so fmt.Sprintf("%+v", req) passes through untouched, and the whole thing is best-effort pattern matching well short of data-loss prevention.

Operational Endpoints

The built-in routes of httpserver expose service internals:

  • /pprof/*option serves runtime profiles: memory layout, goroutine stacks, CPU traces.
  • the index route enumerates every registered endpoint.
  • /metrics can reveal implementation details.
  • /ip makes an outbound call to a third-party service.

Enable them only on an internal or administrative listener that is not reachable from the public internet, or protect them with authentication middleware. The generated example service separates monitoring, private, and public servers for exactly this reason: see /docs/service-scaffolding/.

profiling carries the same warning where it mounts pprof onto httprouter.

Two Boundaries in the SQL Helpers

sqlutil quotes identifiers and string literals for dynamically generated query fragments. Prepared statements and bound parameters remain the tool for runtime data, and the package documentation says as much.

Its default value quoting is correct for MySQL-like databases in the default SQL mode over an ASCII-compatible connection charset. Two cases break it. Under NO_BACKSLASH_ESCAPES mode the backslash escaping is taken literally and corrupts the value. On non-self-synchronizing multibyte charsets such as GBK, Big5, and SJIS, a lead byte can swallow the escaping backslash, which is the classic escape-function injection vector.

Reverse Proxy Boundaries

httpreverseproxy never follows redirects, so a 3xx from the upstream is forwarded verbatim rather than chased, which closes an SSRF vector. When the configured upstream address carries a base path, a request that resolves outside it through . or .. is rejected with 400 before the upstream is contacted; WithLaxBasePath turns that off when the upstream is the authorization boundary. The check does not defend against multiply percent-encoded traversal, so keep untrusted-input defenses at the upstream too.


Previous: /docs/resilience/

Overview: /docs/

Next: /docs/data-and-messaging/