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.
- 1Authenticate once, then authorize every request at three levels: the function, the tenant, the object.
- 2Short access tokens verify locally. Refresh tokens rotate, and a reused one revokes its family.
- 3Row-level security is the second wall: a query that forgets the tenant still sees only its tenant.
- 4Hash passwords with a slow, memory-hard function. Encrypt data with a data key that a KMS wraps.
- 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.
| method | revoke | where | status |
|---|---|---|---|
| Session id in a cookie, state on the server | Delete the session | Redis | Approved |
| JWT for 15 min plus a rotating refresh token | Revocation list, rotation | Service | Approved |
| JWT valid for days, no revocation | Not possible | Service | Not approved |
| API key, stored as a hash, with scopes | Delete the key | Postgres | Server to server |
| Mutual TLS between services | Revoke the certificate | Mesh | Internal |
| Token in localStorage | Delete the token | Browser | No 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.
| algorithm | key | signature | token | verify |
|---|---|---|---|---|
| HS256 | shared secret | 32 B | 281 B | 4.9 µs |
| EdDSA (Ed25519) | private signs, public verifies | 64 B | 324 B | 57 µ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.
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- 1Tested: alg none and HS256 signed with the public key both fail.
- 2A constant-time compare for HMAC. Tested: a payload changed to admin fails.
- 3Allow about 30 s of clock skew between servers, and no more.
- 4One Redis lookup per request. The entry expires when the token would.
Tested source Go: verify · Go: revocation list in Redis
// 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
}
// 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.
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- 1Store a SHA-256 of the token. A leaked table holds no usable tokens.
- 2Tested: after reuse, the newest token in the family fails too.
- 3Commit the revocation, then refuse. A rollback would undo it.
- 4A family is one login. Logout or reuse ends it.
Tested source Go: rotate · SQL: refresh tokens
// 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
}
-- 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.
- 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.
| model | decides on | status |
|---|---|---|
| RBAC: role to actions | The caller's role | Approved |
| RBAC plus an owner check | Role, then the object's owner | Approved |
| ABAC: rules on attributes | User, object, request attributes | Hard to audit |
| ReBAC: a graph of relations | Paths like doc → folder → team → user | Sharing |
| Checks scattered in handlers | Whatever each author wrote | Not 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.
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.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Postgres | Row-level security policies | Every query on a table gets the tenant filter, even when the code forgets it. | Per-user rows, soft deletes |
| Postgres | set_config(..., true) per transaction | The tenant lives for one transaction. A pooled connection cannot leak it. | Request ids in logs |
| Postgres | Roles and GRANT | The service role may insert audit rows, never update or delete them. | Read-only reporting users |
| Postgres | SELECT ... FOR UPDATE | Two refreshes of one token are serialized, so only one wins. | Any single-use token |
| Redis | SET with an expiry | A revocation entry removes itself when the token expires. | Sessions, one-time codes |
| Redis | INCR with an expiry | Count login attempts per account and IP in a window. | API rate limits |
| Service | Ed25519 signatures | Verify tokens locally with a public key; only the auth server signs. | Webhooks, signed URLs |
| Service | argon2id | A password guess costs memory and tens of milliseconds. | Key derivation from a passphrase |
| Service | AES-GCM with associated data | Encrypt and authenticate a record; bind it to its tenant. | Encrypted columns, files |
| KMS | Wrap and unwrap data keys | The master key never leaves; every use is logged and can be denied. | Backups, secrets |
| Secrets manager | Versioned secrets with rotation | No 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.
GET /documents/1 Authorization: Bearer <valid token: carol, globex, admin>
- 1✓ Authenticatesignature, expiry, revocationuser 4, tenant 2, role admin
- 2✓ Rolemay this role do the action?role admin may read
- 3– Tenantrow-level security in Postgresskipped: loaded document 1 by id alone; it is in tenant 1
- 4– Objectdoes the caller own it?skipped: never compared owner 1 with user 4
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.
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;- 1Set per transaction by the service. Not set: NULL, so no row matches.
- 2Writes must also stay inside the tenant. Tested: an insert for another tenant fails with 42501.
| as | statement | result |
|---|---|---|
| service, tenant 1 | SELECT id, title FROM documents ORDER BY id | id = 1, title = Acme roadmap; id = 2, title = Acme payroll |
| service, tenant 2 | SELECT id, title FROM documents ORDER BY id | id = 3, title = Globex launch plan |
| service, no tenant set | SELECT id, title FROM documents ORDER BY id | no rows |
| service, tenant 1 | SELECT title FROM documents WHERE id = 3 | no rows |
| service, tenant 1 | UPDATE documents SET title = 'mine now' WHERE id = 3 | UPDATE 0 |
| service, tenant 1 | INSERT 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 superuser | SELECT count(*) FROM documents | count = 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.
| caller | request | missing check | checked |
|---|---|---|---|
| alice | GET /documents/1 | 200 {"title":"Acme roadmap"} | 200 {"title":"Acme roadmap"} |
| carol | GET /documents/1 | 200 {"title":"Acme roadmap"} | 404 {"error":"Not Found"} |
| alice | PATCH /documents/2 | 200 {"title":"Acme payroll (edited)"} | 403 {"error":"Forbidden"} |
| carol | DELETE /documents/2 | 200 {"title":"Acme payroll (deleted)"} | 404 {"error":"Not Found"} |
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- 1The role table: viewers read, members also edit, admins also delete.
- 2The missing-check handler loads by id alone, on a role that bypasses RLS.
- 3The missing-check handler skips this line. That is the IDOR.
- 4Denials are audited too. Tested: 200 and 404 rows both appear.
Tested source Go: the checked handler
// 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.
| id | risk | control on this sheet |
|---|---|---|
| API1 | Broken object level authorization (IDOR) | Owner check, RLS |
| API2 | Broken authentication | Verify, rotate, rate-limit logins |
| API3 | Broken object property level authorization | Allow-list fields in and out |
| API4 | Unrestricted resource consumption | Rate limits, page size caps |
| API5 | Broken function level authorization | Role table on every route |
| API6 | Unrestricted access to sensitive business flows | Per-flow limits, bot checks |
| API7 | Server side request forgery | Allow-list outbound hosts; block internal addresses |
| API8 | Security misconfiguration | TLS everywhere, least-privilege roles |
| API9 | Improper inventory management | List every endpoint and version; retire old ones |
| API10 | Unsafe consumption of APIs | Validate 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.
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)- 1A unique salt per user: equal passwords give different hashes.
- 2The OWASP minimum for argon2id. Raise it until a login costs what your servers allow.
- 3Parameters live in the string. Rehash at login when they are below the current ones.
- 4A compare that stops at the first difference leaks timing.
Tested source Go: hash and check
// 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.
- 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(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- 1Tested: a ciphertext opened with another tenant id fails.
- 2Cache the data key for a batch of records to cut KMS calls.
- 3Tested: after rotation, the old master key no longer opens the data key.
Tested source Go: seal, open, rotate
// 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.
| event | result | what stops it | saved by |
|---|---|---|---|
| A handler trusts the id in the URL | Users read and change other users' objects. | Owner check in the handler; RLS for the tenant. | RLS |
| A query forgets WHERE tenant_id | Without RLS, every tenant's rows come back. | The policy adds the filter; no tenant set means no rows. | RLS |
| A caller edits the JWT payload | The signature no longer matches. | Verification fails: 401. | Signature |
| An access token leaks | Valid until it expires. | 15-minute expiry; revoke its jti in Redis. | Deny-list |
| A refresh token is stolen | Both parties try to refresh. | The second refresh finds a spent token and revokes the family. | Rotation |
| The password table leaks | Attackers guess offline. | argon2id: about 36 guesses a second per core, against 1,712,329 for SHA-256. | argon2id |
| Credential stuffing on login | Many accounts tried with leaked passwords. | Rate limits per account and IP, breached-password checks, MFA. | Rate limit |
| A backup or disk is stolen | The thief holds the files. | Envelope encryption: data keys are wrapped by the KMS. | KMS |
| A webhook URL points at an internal address | SSRF: the server fetches internal data. | Allow-list hosts; resolve and block private ranges. | Egress check |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | Sessions 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. |
| 2 | Row-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. |
| 3 | Redis 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. |
| 4 | An 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. |
| 5 | A 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.
0 of 9 known
What is the difference between authentication and authorization, and which status code goes with each?
A user logs out. Their JWT is valid for 10 more minutes. How do you stop it?
An attacker changes the role in a JWT payload to admin. What stops it?
Why does the key, not the token header, choose the algorithm?
A refresh token is stolen. How does rotation limit the damage?
GET /orders/1234 returns another customer's order. Name the bug and the fix.
Why argon2id or bcrypt instead of SHA-256 for passwords?
Why encrypt each record with its own data key and wrap that key with a KMS?
One table holds rows of 10,000 tenants. How do you make a forgotten WHERE tenant_id safe?
- 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.