System Design
O5

Security

Most API breaches are authorization bugs, not broken crypto. Check who calls, what they may do, and which object they touch, on every request, in layers.

Not startedSaved in this browser only.
  1. 1Authenticate once, then authorize every request at three levels: the function, the tenant, the object.
  2. 2Short access tokens verify locally. Refresh tokens rotate, and a reused one revokes its family.
  3. 3Row-level security is the second wall: a query that forgets the tenant still sees only its tenant.
  4. 4Hash passwords with a slow, memory-hard function. Encrypt data with a data key that a KMS wraps.
O5
    A

    Who, then what

    the checks on every request
    authenticationauthorization: function, tenant, objectAuthenticatewho is calling?401Rolemay this role do it?403Tenantis the row visible?404Objectis it the caller’s?403audit log: actor, action, target, status, time; append only404, not 403, for another tenant's row: the caller must not learn that it exists.
    • Authentication: who is calling. Authorization: may this caller do this, to this object.
    • Each check is cheap. Skipping one is the bug.

    Authentication tells me who calls. Authorization then checks the function, the tenant and the object, and each check fails with its own status.

    B

    Sessions or tokens

    how a caller proves who it is
    methodrevokewherestatus
    Session id in a cookie, state on the serverDelete the sessionRedisApproved
    JWT for 15 min plus a rotating refresh tokenRevocation list, rotationServiceApproved
    JWT valid for days, no revocationNot possibleServiceNot approved
    API key, stored as a hash, with scopesDelete the keyPostgresServer to server
    Mutual TLS between servicesRevoke the certificateMeshInternal
    Token in localStorageDelete the tokenBrowserNo XSS

    A session costs one store lookup per request. A JWT costs a signature check and no lookup, unless you check revocations.

    For a browser I use a server session or a short JWT with a rotating refresh token. Services use mTLS or signed tokens; integrations use scoped API keys.

    C

    Anatomy of a JWT

    recorded from the lab
    EdDSA token, 324 bytes: header . payload . signatureeyJhbGciOiJF…eyJpc3MiOiJodHRwczovL2F1dGguZX…7SiUMf0OICRT…headeralg: EdDSAtyp: JWTkid: ed-1payload (signed, not encrypted)iss: https://auth.example.c…who issued itsub: 1the useraud: documentswho may accept ittenant: 1tenant idrole: memberrole at issue timeiat: 1791287940issued atexp: 1791288900expires: 15 minjti: 4f1c9aid, for revocationsignature64 bytesover header.payloadAnyone can read the payload. Only the key holder can change it without breaking the signature.
    algorithmkeysignaturetokenverify
    HS256shared secret32 B281 B4.9 µs
    EdDSA (Ed25519)private signs, public verifies64 B324 B57 µs

    With HS256, every verifier holds the secret and can mint tokens. With EdDSA, only the auth server can.

    A JWT is a signed claim set: readable by anyone, changeable by no one without the key. I keep it to 15 minutes.

    D

    Verify a token

    pseudo code
    verify an access tokenpseudo code
    verify(token, now):
      header, payload, sig = split token at "."
      key = keys[header.kid]                  // unknown kid: reject
      IF header.alg != key.alg: reject        // the key decides1
      IF sig does not match2 header.payload: reject
      IF now >= payload.exp: reject           // 15 min access token3
      IF payload.iss, payload.aud are wrong: reject
      IF payload.jti in revocation list4: reject
      RETURN payload                          // user, tenant, role
    1. 1Tested: alg none and HS256 signed with the public key both fail.
    2. 2A constant-time compare for HMAC. Tested: a payload changed to admin fails.
    3. 3Allow about 30 s of clock skew between servers, and no more.
    4. 4One Redis lookup per request. The entry expires when the token would.
    Tested source Go: verify · Go: revocation list in Redis
    Go: verifygo
    
    // Keys are the verification keys, by key id. The key, not the token header, decides the
    // algorithm, so a token cannot switch to "none" or to HS256 signed with a public key.
    type Keys struct {
      HMAC     map[string][]byte
      Ed25519  map[string]ed25519.PublicKey
      Issuer   string
      Audience string
    }
    
    // Verify checks a token's signature, algorithm, time window, issuer and audience, and returns
    // its claims. Revocation is a separate check, because it needs a store.
    func (k Keys) Verify(token string, now time.Time) (Claims, error) {
      parts := strings.Split(token, ".")
      if len(parts) != 3 {
        return Claims{}, ErrMalformed
      }
      var h header
      if err := decode(parts[0], &h); err != nil {
        return Claims{}, err
      }
      signed := []byte(parts[0] + "." + parts[1])
      sig, err := b64.DecodeString(parts[2])
      if err != nil {
        return Claims{}, ErrMalformed
      }
      switch {
      case k.HMAC[h.Kid] != nil:
        if h.Alg != "HS256" {
          return Claims{}, ErrAlg
        }
        m := hmac.New(sha256.New, k.HMAC[h.Kid])
        m.Write(signed)
        if !hmac.Equal(sig, m.Sum(nil)) { // constant time
          return Claims{}, ErrSignature
        }
      case k.Ed25519[h.Kid] != nil:
        if h.Alg != "EdDSA" {
          return Claims{}, ErrAlg
        }
        if !ed25519.Verify(k.Ed25519[h.Kid], signed, sig) {
          return Claims{}, ErrSignature
        }
      default:
        return Claims{}, ErrUnknownKey
      }
      var c Claims
      if err := decode(parts[1], &c); err != nil {
        return Claims{}, err
      }
      switch {
      case now.Unix() >= c.Exp:
        return Claims{}, ErrExpired
      case c.Iat > now.Unix()+30: // 30 s of clock skew
        return Claims{}, ErrNotYet
      case c.Iss != k.Issuer || c.Aud != k.Audience:
        return Claims{}, ErrAudience
      }
      return c, nil
    }
    
    Go: revocation list in Redisgo
    
    // Revocations is a deny-list of token ids in Redis. An entry lives only until the token would
    // expire anyway, so the list stays as small as the tokens revoked in one token lifetime.
    type Revocations struct{ R *redis.Client }
    
    // Revoke denies the token with id jti for ttl, the time the token has left.
    func (v Revocations) Revoke(ctx context.Context, jti string, ttl time.Duration) error {
      if err := v.R.Set(ctx, "revoked:"+jti, 1, ttl).Err(); err != nil {
        return fmt.Errorf("revoke %s: %w", jti, err)
      }
      return nil
    }
    
    // Revoked reports whether jti is on the deny-list. Every request pays this one lookup.
    func (v Revocations) Revoked(ctx context.Context, jti string) (bool, error) {
      n, err := v.R.Exists(ctx, "revoked:"+jti).Result()
      if err != nil {
        return false, fmt.Errorf("check revocation of %s: %w", jti, err)
      }
      return n == 1, nil
    }
    

    I look up the key by kid and let the key decide the algorithm. Then I check the signature, expiry, issuer, audience and the revocation list.

    E

    Refresh tokens and revocation

    rotation with reuse detection
    UserThiefAuth servertoken hashes in Postgresrefresh with R1new access token + R2; R1 spentrefresh with a stolen copy of R1R1 was spent: revoke familyrefresh with R2family revoked: log in againAccess token: 15 minutes, checked locally. Refresh token: 30 days, checked in the database, works once.
    rotate a refresh tokenpseudo code
    rotate(refresh_token):
      BEGIN
        row = find hash(refresh_token) FOR UPDATE1
        IF no row OR family revoked: RETURN log in again
        IF row already used:                  // a copy exists2
          revoke the whole family; COMMIT3
          RETURN log in again
        IF expired: RETURN log in again
        mark row used
        insert hash(new token), same family4
      COMMIT; RETURN new token
    1. 1Store a SHA-256 of the token. A leaked table holds no usable tokens.
    2. 2Tested: after reuse, the newest token in the family fails too.
    3. 3Commit the revocation, then refuse. A rollback would undo it.
    4. 4A family is one login. Logout or reuse ends it.
    Tested source Go: rotate · SQL: refresh tokens
    Go: rotatego
    
    // Rotate spends a refresh token and returns the next one in its family. A token works once. If a
    // spent token comes back, two parties hold it, so one of them stole it: revoke the whole family,
    // and both must log in again.
    func (r Refresh) Rotate(ctx context.Context, tok string, now time.Time) (next string, user int64, err error) {
      var reused bool
      err = pgx.BeginFunc(ctx, r.DB, func(tx pgx.Tx) error {
        var family int64
        var expires time.Time
        var used, revoked *time.Time
        err := tx.QueryRow(ctx, schema["rotate_find"], hashToken(tok)).Scan(&family, &user, &expires, &used, &revoked)
        switch {
        case errors.Is(err, pgx.ErrNoRows):
          return ErrRefreshUnknown
        case err != nil:
          return fmt.Errorf("find refresh token: %w", err)
        case revoked != nil:
          return ErrRefreshRevoked
        case used != nil:
          if _, err := tx.Exec(ctx, schema["revoke_family"], family, now); err != nil {
            return fmt.Errorf("revoke family %d: %w", family, err)
          }
          reused = true
          return nil // commit the revocation
        case !now.Before(expires):
          return ErrRefreshExpired
        }
        if _, err := tx.Exec(ctx, schema["rotate_use"], hashToken(tok), now); err != nil {
          return fmt.Errorf("spend refresh token: %w", err)
        }
        if next, err = newToken(); err != nil {
          return err
        }
        if _, err := tx.Exec(ctx, schema["rotate_issue"], hashToken(next), family, user, now.Add(r.TTL)); err != nil {
          return fmt.Errorf("issue refresh token: %w", err)
        }
        return nil
      })
      if reused {
        return "", 0, ErrRefreshReused
      }
      return next, user, err
    }
    
    SQL: refresh tokenssql
    -- The token itself is never stored, only its hash. One family per login.
    CREATE TABLE refresh_tokens (
      hash       bytea       PRIMARY KEY,
      family     bigint      NOT NULL,
      user_id    bigint      NOT NULL REFERENCES users (id),
      expires_at timestamptz NOT NULL,
      used_at    timestamptz,
      revoked_at timestamptz
    );
    CREATE INDEX refresh_tokens_family ON refresh_tokens (family);

    Refresh tokens are opaque, stored as hashes, and work once. A spent token that comes back means a copy exists, so I revoke the whole family.

    F

    OAuth 2 and OpenID Connect

    authorization code with PKCE
    AppAuthorization serverAPI1 redirect: challenge = SHA256(verifier)2 user logs in, consents; one-time code3 code + verifier4 access, refresh, ID token5 Authorization: Bearer <access token>A stolen code is useless without the verifier, which never left the app.
    • OAuth 2 delegates access: the app gets a token, never the password.
    • OpenID Connect adds an ID token that says who the user is.
    • PKCE binds the code to the app that asked for it. Use it for every client.

    Sources: RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), OpenID Connect Core 1.0.

    Users log in at the authorization server. The app gets a one-time code and trades it, with its PKCE verifier, for tokens.

    G

    Access models

    what a decision reads
    modeldecides onstatus
    RBAC: role to actionsThe caller's roleApproved
    RBAC plus an owner checkRole, then the object's ownerApproved
    ABAC: rules on attributesUser, object, request attributesHard to audit
    ReBAC: a graph of relationsPaths like doc → folder → team → userSharing
    Checks scattered in handlersWhatever each author wroteNot approved
    • Zanzibar stores relation tuples and answers "can user U do R on object O?" for many services, with snapshot tokens against stale answers.

    Source: Pang et al., Zanzibar: Google's Consistent, Global Authorization System, USENIX ATC 2019.

    I start with roles. I add attributes for rules like region or time, and a relationship graph when users share objects with each other.

    H

    The request path

    click a step; its path lights up
    Clientbrowser, appGatewayTLS, rate limitsAuth serverlogin, tokensAPI serviceverify, authorizeRedisrevocations, limitsPostgresusers, RLS, auditKMSmaster keys

    Step 1: Log in

    • Check the password against its argon2id hash.
    • Rate-limit attempts per account and per IP address.
    • Issue a 15-minute access token and a refresh token.

    If it fails

    Wrong password: count the attempt and slow the next one. Too many: lock the account for a while and alert.

    The gateway terminates TLS and rate-limits. The service verifies the token locally, authorizes the role and the object, and queries Postgres with row-level security and an audit row.

    I

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    PostgresRow-level security policiesEvery query on a table gets the tenant filter, even when the code forgets it.Per-user rows, soft deletes
    Postgresset_config(..., true) per transactionThe tenant lives for one transaction. A pooled connection cannot leak it.Request ids in logs
    PostgresRoles and GRANTThe service role may insert audit rows, never update or delete them.Read-only reporting users
    PostgresSELECT ... FOR UPDATETwo refreshes of one token are serialized, so only one wins.Any single-use token
    RedisSET with an expiryA revocation entry removes itself when the token expires.Sessions, one-time codes
    RedisINCR with an expiryCount login attempts per account and IP in a window.API rate limits
    ServiceEd25519 signaturesVerify tokens locally with a public key; only the auth server signs.Webhooks, signed URLs
    Serviceargon2idA password guess costs memory and tens of milliseconds.Key derivation from a passphrase
    ServiceAES-GCM with associated dataEncrypt and authenticate a record; bind it to its tenant.Encrypted columns, files
    KMSWrap and unwrap data keysThe master key never leaves; every use is logged and can be denied.Backups, secrets
    Secrets managerVersioned secrets with rotationNo secret in code, images or environment files in the repository.Database passwords, API keys

    Postgres gives me row-level security, roles and grants, and an append-only audit table. Redis holds revocations and rate limits. A KMS keeps master keys out of my service.

    J

    Try it: one request, every check

    recorded from Postgres and the lab service
    caller
    token
    document
    action
    handler
    GET /documents/1
    Authorization: Bearer <valid token: carol, globex, admin>
    1. 1✓ Authenticatesignature, expiry, revocationuser 4, tenant 2, role admin
    2. 2✓ Rolemay this role do the action?role admin may read
    3. 3– Tenantrow-level security in Postgresskipped: loaded document 1 by id alone; it is in tenant 1
    4. 4– Objectdoes the caller own it?skipped: never compared owner 1 with user 4
    200 OKreturned "Acme roadmap"

    IDOR: carol reached document 1 of acme by changing the id in the URL. The checked handler returns 404 Not Found for the same request.

    Each check covers a different mistake. Turn one off and the request table shows exactly which callers get through.

    K

    Row-level security

    two tenants, recorded from Postgres
    the policysql
    ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
    CREATE POLICY tenant_isolation ON documents
      USING (tenant_id = nullif(current_setting('app.tenant_id', true)1, '')::bigint)
      WITH CHECK2 (tenant_id = nullif(current_setting('app.tenant_id', true), '')::bigint);
    GRANT SELECT, INSERT, UPDATE, DELETE ON documents TO security_app;
    1. 1Set per transaction by the service. Not set: NULL, so no row matches.
    2. 2Writes must also stay inside the tenant. Tested: an insert for another tenant fails with 42501.
    asstatementresult
    service, tenant 1SELECT id, title FROM documents ORDER BY idid = 1, title = Acme roadmap; id = 2, title = Acme payroll
    service, tenant 2SELECT id, title FROM documents ORDER BY idid = 3, title = Globex launch plan
    service, no tenant setSELECT id, title FROM documents ORDER BY idno rows
    service, tenant 1SELECT title FROM documents WHERE id = 3no rows
    service, tenant 1UPDATE documents SET title = 'mine now' WHERE id = 3UPDATE 0
    service, tenant 1INSERT INTO documents (id, tenant_id, owner_id, title) VALUES (9, 2, 4, 'planted')42501: new row violates row-level security policy for table "documents"
    table owner, a superuserSELECT count(*) FROM documentscount = 3

    Superusers and roles with BYPASSRLS skip every policy. The table owner does too, unless the table has FORCE ROW LEVEL SECURITY.

    Each transaction sets its tenant. The policy filters every read and checks every write, and the service role cannot bypass it.

    L

    IDOR: the bug and the fix

    recorded HTTP requests
    callerrequestmissing checkchecked
    aliceGET /documents/1200 {"title":"Acme roadmap"}200 {"title":"Acme roadmap"}
    carolGET /documents/1200 {"title":"Acme roadmap"}404 {"error":"Not Found"}
    alicePATCH /documents/2200 {"title":"Acme payroll (edited)"}403 {"error":"Forbidden"}
    carolDELETE /documents/2200 {"title":"Acme payroll (deleted)"}404 {"error":"Not Found"}
    handle one requestpseudo code
    handle(request):
      claims = verify(bearer token)           // else 401
      IF action NOT IN permissions[claims.role]:
        RETURN 403                            // function level1
      BEGIN as service role, tenant = claims.tenant
        doc = SELECT document by id           // RLS adds the tenant2
        IF no row: RETURN 404                 // object hidden
        IF edit AND not admin AND doc.owner != claims.user:
          RETURN 403                          // object level3
        do the action; write the audit row4
      COMMIT
    1. 1The role table: viewers read, members also edit, admins also delete.
    2. 2The missing-check handler loads by id alone, on a role that bypasses RLS.
    3. 3The missing-check handler skips this line. That is the IDOR.
    4. 4Denials are audited too. Tested: 200 and 404 rows both appear.
    Tested source Go: the checked handler
    Go: the checked handlergo
    
    // Handle runs every check in order and stops at the first that fails.
    func (s *Service) Handle(ctx context.Context, req Request) (Decision, error) {
      var d Decision
      // 1. Authentication: who is calling? A bad token is 401.
      c, err := s.Keys.Verify(req.Token, s.Now())
      if err == nil {
        var revoked bool
        if revoked, err = s.Revoked.Revoked(ctx, c.Jti); err != nil {
          return d, err
        }
        if revoked {
          err = ErrRevoked
        }
      }
      if err != nil {
        d.add("token", "fail", err.Error())
        d.Status = http.StatusUnauthorized
        return d, nil
      }
      d.add("token", "pass", fmt.Sprintf("user %d, tenant %d, role %s", c.Sub, c.Tenant, c.Role))
    
      // 2. Function level: may this role take this action at all? No is 403.
      if !slices.Contains(Permissions[c.Role], req.Action) {
        d.add("role", "fail", fmt.Sprintf("role %s may not %s", c.Role, req.Action))
        d.Status = http.StatusForbidden
        return d, nil
      }
      d.add("role", "pass", fmt.Sprintf("role %s may %s", c.Role, req.Action))
    
      if s.MissingObjectCheck {
        return s.handleWithoutObjectCheck(ctx, c, req, d)
      }
    
      // 3 and 4 run in one transaction, as the service role, with the caller's tenant set.
      err = pgx.BeginFunc(ctx, s.DB, func(tx pgx.Tx) error {
        if _, err := tx.Exec(ctx, schema["as_tenant"], strconv.FormatInt(c.Tenant, 10)); err != nil {
          return fmt.Errorf("set tenant: %w", err)
        }
        // 3. Tenant: row-level security returns only this tenant's rows. Not found is 404,
        // so a caller cannot learn that another tenant's document exists.
        var tenant, owner int64
        var title string
        err := tx.QueryRow(ctx, schema["load"], req.Doc).Scan(&tenant, &owner, &title)
        if errors.Is(err, pgx.ErrNoRows) {
          d.add("tenant", "fail", fmt.Sprintf("row-level security shows no document %d in tenant %d", req.Doc, c.Tenant))
          d.Status = http.StatusNotFound
          return s.audit(ctx, tx, c, req, d.Status)
        }
        if err != nil {
          return fmt.Errorf("load document %d: %w", req.Doc, err)
        }
        d.add("tenant", "pass", fmt.Sprintf("document %d is in tenant %d", req.Doc, tenant))
    
        // 4. Object level: a member edits only documents that member owns.
        if req.Action == "edit" && c.Role != "admin" && owner != c.Sub {
          d.add("owner", "fail", fmt.Sprintf("user %d owns document %d, not user %d", owner, req.Doc, c.Sub))
          d.Status = http.StatusForbidden
          return s.audit(ctx, tx, c, req, d.Status)
        }
        d.add("owner", "pass", ownerDetail(req.Action, c, owner))
    
        if d.Title, err = act(ctx, tx, req, title); err != nil {
          return err
        }
        d.Status = http.StatusOK
        return s.audit(ctx, tx, c, req, d.Status)
      })
      return d, err
    }
    

    I never trust the id in the URL. I load the object through the caller's scope and compare the owner.

    M

    OWASP API Security Top 10

    2023 edition
    idriskcontrol on this sheet
    API1Broken object level authorization (IDOR)Owner check, RLS
    API2Broken authenticationVerify, rotate, rate-limit logins
    API3Broken object property level authorizationAllow-list fields in and out
    API4Unrestricted resource consumptionRate limits, page size caps
    API5Broken function level authorizationRole table on every route
    API6Unrestricted access to sensitive business flowsPer-flow limits, bot checks
    API7Server side request forgeryAllow-list outbound hosts; block internal addresses
    API8Security misconfigurationTLS everywhere, least-privilege roles
    API9Improper inventory managementList every endpoint and version; retire old ones
    API10Unsafe consumption of APIsValidate third-party responses like user input

    Source: OWASP API Security Top 10, 2023. Injection sits under API8 and API10: use bound parameters, never string-built SQL.

    Broken object level authorization is the top API risk. I check the object on every request and keep RLS as a second wall.

    N

    Password hashing cost

    measured on one core
    Guesses per second on one core, log scale1101001k10k100k1M10MSHA-256, no salt: 1,712,329 hashes per secondSHA-256, no salt1,712,329PBKDF2, 600k rounds: 4 hashes per secondPBKDF2, 600k rounds4bcrypt, cost 10: 18 hashes per secondbcrypt, cost 1018bcrypt, cost 12: 5 hashes per secondbcrypt, cost 125bcrypt, cost 14: 1 hashes per secondbcrypt, cost 141scrypt, N=2^17: 2 hashes per secondscrypt, N=2^172argon2id, 19 MiB: 36 hashes per secondargon2id, 19 MiB36argon2id, 64 MiB, p=4: 22 hashes per secondargon2id, 64 MiB, p=422hashes per second, log scale
    store and check a passwordpseudo code
    hash_password(pw):
      salt = 16 random bytes1
      key = argon2id(pw, salt, m = 19 MiB, t = 2, p = 12)
      RETURN "$argon2id$v=19$m=19456,t=2,p=1$" + salt + "$" + key
    
    check_password(pw, stored):
      read m, t, p, salt from stored          // old hashes still verify3
      RETURN constant_time_equal4(argon2id(pw, salt, m, t, p), key)
    1. 1A unique salt per user: equal passwords give different hashes.
    2. 2The OWASP minimum for argon2id. Raise it until a login costs what your servers allow.
    3. 3Parameters live in the string. Rehash at login when they are below the current ones.
    4. 4A compare that stops at the first difference leaks timing.
    Tested source Go: hash and check
    Go: hash and checkgo
    
    // Argon2id parameters for stored passwords: 19 MiB, 2 passes, 1 lane (the OWASP minimum).
    const (
      argonMemKiB = 19 * 1024
      argonTime   = 2
      argonLanes  = 1
    )
    
    // HashPassword returns a self-describing string: algorithm, version, parameters, salt, hash.
    // The parameters travel with the hash, so a later login can verify old hashes and rehash them.
    func HashPassword(pw string) (string, error) {
      salt := make([]byte, 16)
      if _, err := rand.Read(salt); err != nil {
        return "", fmt.Errorf("salt: %w", err)
      }
      key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemKiB, argonLanes, 32)
      enc := base64.RawStdEncoding
      return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
        argon2.Version, argonMemKiB, argonTime, argonLanes, enc.EncodeToString(salt), enc.EncodeToString(key)), nil
    }
    
    // CheckPassword recomputes the hash with the stored salt and parameters, and compares in
    // constant time.
    func CheckPassword(pw, stored string) (bool, error) {
      var v int
      var m, t uint32
      var p uint8
      parts := strings.Split(stored, "$")
      if len(parts) != 6 || parts[1] != "argon2id" {
        return false, errors.New("not an argon2id hash")
      }
      if _, err := fmt.Sscanf(parts[2], "v=%d", &v); err != nil {
        return false, fmt.Errorf("read version: %w", err)
      }
      if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
        return false, fmt.Errorf("read parameters: %w", err)
      }
      enc := base64.RawStdEncoding
      salt, err := enc.DecodeString(parts[4])
      if err != nil {
        return false, fmt.Errorf("read salt: %w", err)
      }
      want, err := enc.DecodeString(parts[5])
      if err != nil {
        return false, fmt.Errorf("read hash: %w", err)
      }
      got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
      return subtle.ConstantTimeCompare(got, want) == 1, nil
    }
    

    Settings from the OWASP Password Storage Cheat Sheet: argon2id m=19 MiB, t=2, p=1; scrypt N=2^17; bcrypt cost 10 or more; PBKDF2-SHA256 600,000. bcrypt reads only 72 bytes.

    I hash passwords with argon2id at 19 MiB and 2 passes. A guess then costs tens of milliseconds and memory, against microseconds for SHA-256.

    O

    Encryption in transit and at rest

    envelope encryption
    recordcard ending 4242data keyrandom, 256 bitsAES-GCM, localAAD = tenant idKMS: wrapmaster key stays insiderow in Postgresciphertextwrapped data keymaster key idRotate the master key: re-wrap each data key. The ciphertext does not change.A stolen disk or backup holds only wrapped keys. Decryption needs a logged KMS call.
    • TLS 1.3 on every hop, inside the network too. Terminate at the gateway and again at the service.
    • Secrets live in a secrets manager, are read at start, and rotate on a schedule.
    seal and rotatepseudo code
    seal(record, tenant):
      data_key = 32 random bytes
      ct = AES-GCM(data_key, record, aad = tenant1)   // local, fast
      wrapped = KMS.wrap(master_key_id, data_key)   // one call2
      store ct, wrapped, master_key_id
    
    rotate(row, new_master):
      data_key = KMS.unwrap(row.wrapped)
      row.wrapped = KMS.wrap(new_master, data_key)  // ct unchanged3
    1. 1Tested: a ciphertext opened with another tenant id fails.
    2. 2Cache the data key for a batch of records to cut KMS calls.
    3. 3Tested: after rotation, the old master key no longer opens the data key.
    Tested source Go: seal, open, rotate
    Go: seal, open, rotatego
    
    // Seal encrypts plain with a new data key. aad (for example the tenant id) is authenticated but
    // not encrypted: a ciphertext moved to another tenant's row fails to open.
    func Seal(kms *KMS, keyID string, plain, aad []byte) (Envelope, error) {
      dataKey := make([]byte, 32)
      if _, err := rand.Read(dataKey); err != nil {
        return Envelope{}, fmt.Errorf("data key: %w", err)
      }
      a, err := gcm(dataKey)
      if err != nil {
        return Envelope{}, err
      }
      ct, err := seal(a, plain, aad) // local AES-GCM: no network call per record
      if err != nil {
        return Envelope{}, err
      }
      wrapped, err := kms.Wrap(keyID, dataKey) // one KMS call per data key
      if err != nil {
        return Envelope{}, err
      }
      return Envelope{KeyID: keyID, WrappedKey: wrapped, Ciphertext: ct}, nil
    }
    
    // Open unwraps the data key through the KMS, then decrypts locally.
    func Open(kms *KMS, e Envelope, aad []byte) ([]byte, error) {
      dataKey, err := kms.Unwrap(e.KeyID, e.WrappedKey)
      if err != nil {
        return nil, err
      }
      a, err := gcm(dataKey)
      if err != nil {
        return nil, err
      }
      return open(a, e.Ciphertext, aad)
    }
    
    // Rotate re-wraps the data key under a new master key. The data itself is not touched.
    func Rotate(kms *KMS, e Envelope, newKeyID string) (Envelope, error) {
      dataKey, err := kms.Unwrap(e.KeyID, e.WrappedKey)
      if err != nil {
        return Envelope{}, err
      }
      wrapped, err := kms.Wrap(newKeyID, dataKey)
      if err != nil {
        return Envelope{}, err
      }
      return Envelope{KeyID: newKeyID, WrappedKey: wrapped, Ciphertext: e.Ciphertext}, nil
    }
    

    TLS protects data in transit. At rest, each record gets its own data key, and a KMS wraps that key, so a stolen disk holds nothing usable.

    P

    Failure cases

    what breaks, and what stops it
    eventresultwhat stops itsaved by
    A handler trusts the id in the URLUsers read and change other users' objects.Owner check in the handler; RLS for the tenant.RLS
    A query forgets WHERE tenant_idWithout RLS, every tenant's rows come back.The policy adds the filter; no tenant set means no rows.RLS
    A caller edits the JWT payloadThe signature no longer matches.Verification fails: 401.Signature
    An access token leaksValid until it expires.15-minute expiry; revoke its jti in Redis.Deny-list
    A refresh token is stolenBoth parties try to refresh.The second refresh finds a spent token and revokes the family.Rotation
    The password table leaksAttackers guess offline.argon2id: about 36 guesses a second per core, against 1,712,329 for SHA-256.argon2id
    Credential stuffing on loginMany accounts tried with leaked passwords.Rate limits per account and IP, breached-password checks, MFA.Rate limit
    A backup or disk is stolenThe thief holds the files.Envelope encryption: data keys are wrapped by the KMS.KMS
    A webhook URL points at an internal addressSSRF: the server fetches internal data.Allow-list hosts; resolve and block private ranges.Egress check
    Q

    Scale ladder

    start simple; climb on a signal
    Each step adds one component1sessions, roles2+ RLS, audit3+ Redis limits4+ auth server, JWT5+ authz servicemore load →
    Work per core against demand1101001k10k100k1MDemand: API requests: 10,000 operations per secondDemand: API requests10,000Demand: logins: 100 operations per secondDemand: logins100HS256 verify, 1 core: 202,758 operations per secondHS256 verify, 1 core202,758EdDSA verify, 1 core: 17,568 operations per secondEdDSA verify, 1 core17,568argon2id login, 1 core: 36 operations per secondargon2id login, 1 core36operations per second, log scale
    stepaddit handlesmove up when you see
    1Sessions in Postgres, a role table, owner checks in code.One app and one database. A session lookup is one indexed read.Many tenants in shared tables; one missed WHERE would leak data.
    2Row-level security and an append-only audit log.Any number of tenants per table, with the filter enforced in the database.Login attacks, or session reads that load the database.
    3Redis for sessions, revocations and login rate limits.Session and limit checks in under a millisecond, off the database.Many services that each need to know the caller.
    4An auth server (OIDC) that issues short EdDSA tokens.Each service verifies locally: about 17,568 verifications a second per core here.Sharing rules (folders, teams, links) that roles cannot express.
    5A central authorization service with relation tuples, Zanzibar style.One answer to "can U do R on O" for every service, with consistent snapshots.Top of the ladder.

    Demand is an example. Logins are the costly path: 100 logins a second × 28 ms of argon2id is about 2.8 cores.

    I start with sessions in one database and role checks in code. I add RLS when tenants share tables, short JWTs when services multiply, and a central authorization service when sharing rules grow.

    R

    Drill

    predict, then reveal

    0 of 9 known

    1. What is the difference between authentication and authorization, and which status code goes with each?

    2. A user logs out. Their JWT is valid for 10 more minutes. How do you stop it?

    3. An attacker changes the role in a JWT payload to admin. What stops it?

    4. Why does the key, not the token header, choose the algorithm?

    5. A refresh token is stolen. How does rotation limit the damage?

    6. GET /orders/1234 returns another customer's order. Name the bug and the fix.

    7. Why argon2id or bcrypt instead of SHA-256 for passwords?

    8. Why encrypt each record with its own data key and wrap that key with a KMS?

    9. One table holds rows of 10,000 tenants. How do you make a forgotten WHERE tenant_id safe?

    S

    Numbers to say

    measured in the lab
    argon2id
    28 ms per hash at 19 MiB, t=2: about 36 guesses a second per core.
    bcrypt
    54 ms at cost 10, 210 ms at cost 12: each step doubles the work.
    SHA-256
    About 1,712,329 hashes a second per core: never for passwords.
    JWT verify
    HS256 4.9 µs, EdDSA 57 µs, with no network call.
    token
    324 bytes with 8 claims; the Ed25519 signature is 64 bytes.
    lifetimes
    Access token 15 min. Refresh token 30 days, single use.

    Go 1.26 on an 8-core laptop, one goroutine, median of repeated runs. Use these as orders of magnitude.