Stage 7 · Master
Phase 4 — Authentication Service
Password Hashing
Stand up the Authentication module's own service and database, then establish a measured bcrypt cost policy that can evolve without invalidating existing credentials.
Credentials Are a Different Authority Than Identity
user-service answered 'who is this person and which organizations do they belong to.' It deliberately never touched a password. auth-service exists to answer a narrower, higher-stakes question: 'can this request prove it is who it claims to be.' Splitting these means a bug in profile editing can never leak a password hash, and a future credential-rotation incident never requires touching the user directory at all.
credentials.user_id is a value reference to a row that lives in hoa_user, a different database entirely. auth-service calls user-service (or reads an event-driven projection) when it needs to confirm a user_id actually exists — the same cross-service reference pattern the Relationships lesson established for memberships.organization_id.
Scaffolding auth-service
| Field | Auth Service tag | Difference from User Service |
|---|---|---|
| Port | env:"AUTH_SERVICE_PORT" env-default:"8083" | Independent listener |
| DatabaseURL | env:"AUTH_SERVICE_DATABASE_URL" env-required:"true" | Independent credential database |
| Environment | env:"APP_ENV" env-default:"development" | Unchanged shared deployment environment |
Apply only those tag changes to the Config struct copied when the service was scaffolded; Load continues to call sharedconfig.Load(&cfg) exactly as User Service already does. Reprinting the loader would teach nothing new.
A Schema That Lets bcrypt Carry Its Own Cost
A bcrypt hash embeds its algorithm version, cost, salt, and derived checksum in one encoded value. The table therefore stores one password_hash rather than separate tuning columns. When policy raises the cost, auth-service reads the old cost with bcrypt.Cost and transparently replaces the hash after the next successful login—no schema migration or forced password reset required.
CREATE TABLE credentials (
user_id uuid PRIMARY KEY,
password_hash text NOT NULL,
failed_attempts integer NOT NULL DEFAULT 0,
locked_until timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
user_id is the primary key, not a separate uuid id column — a user has exactly one credentials row (or none, for an invited-but-not-yet-activated account), so the natural key is the correct key.
DROP TABLE credentials;
golang-migrate pairs every up file with a down file of the same version number; this one simply drops what the up file created.
bcrypt as an Explicit, Measured Policy
| Decision | Chosen policy | Why |
|---|---|---|
| Cost | 12 initially | Expensive enough to slow guessing, then verified against the production latency budget |
| Rehash | On successful login | Old hashes become stronger organically without a fleet-wide reset |
| Input boundary | Reject passwords over 72 bytes | bcrypt cannot safely process a longer input |
| Storage | One encoded hash | bcrypt embeds its version, cost, salt, and checksum |
package credential
import (
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
)
var ErrPasswordTooLong = errors.New("password exceeds bcrypt's 72-byte limit")
const DefaultCost = 12
// Hash rejects inputs bcrypt cannot represent, then lets bcrypt generate a
// cryptographically random salt and encode the cost into the returned hash.
func Hash(password string, cost int) (string, error) {
if len([]byte(password)) > 72 {
return "", ErrPasswordTooLong
}
encoded, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(encoded), nil
}
// Verify converts an ordinary mismatch into (false, nil), while preserving
// malformed-hash and internal failures for the caller to surface safely.
func Verify(password, encoded string) (bool, error) {
err := bcrypt.CompareHashAndPassword([]byte(encoded), []byte(password))
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("verify password: %w", err)
}
return true, nil
}
// NeedsRehash compares the embedded cost with current policy.
func NeedsRehash(encoded string, currentCost int) bool {
cost, err := bcrypt.Cost([]byte(encoded))
if err != nil {
return true
}
return cost < currentCost
}CompareHashAndPassword performs bcrypt's verification path; application code never compares derived bytes itself. Treat malformed stored hashes as operational errors, but return the same public 401 response used for a wrong password so storage corruption does not become an account-enumeration signal.
On every successful login, NeedsRehash reads the cost embedded in the stored bcrypt string. If it is lower than DefaultCost, auth-service hashes the password again and updates that row in the same login transaction. Never rehash before verification: an attacker-supplied password must not replace the real credential.
Verifying the Hasher in Isolation
Applied exercise
Measure and choose the bcrypt cost for this platform
auth-service starts at cost 12, but an unexplained constant is not a production policy.
- Write a BenchmarkHash table for costs 10, 11, 12, and 13 and run it on production-equivalent CPU limits.
- Choose the highest cost that keeps p95 login latency within the service budget under expected concurrent login load.
- Add a test proving an existing cost-10 hash returns true from NeedsRehash when DefaultCost is 12.
- Propose a concrete cadence for re-running the benchmark as hardware and traffic change.
Deliverable
A short written benchmark plan plus a stated revisit policy.
Completion checks
- The plan connects the hashing benchmark to actual concurrent login-path latency.
- The revisit policy is concrete (a cadence or trigger), not just 'periodically'.
How can auth-service raise bcrypt's cost without adding columns or forcing every user to reset their password?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.