M09 — Authentication & Authorization

Phase 3 · Auth & Authz · Sessions · JWT · OAuth2 · RBAC · Argon2
Phase 3 Requires M03 (REST) C / OpenSSL / libsodium
What this module covers
Authentication (AuthN) answers "Who are you?"; Authorization (AuthZ) answers "What are you allowed to do?" Both are security-critical and surprisingly nuanced. This module builds from first principles — cookies and sessions — through stateless JWT tokens, to OAuth2 delegation, RBAC/ABAC access control, and cryptographically-correct password storage. All code examples use C with OpenSSL and libsodium.
Why get this right
  • Broken authentication is #1 on OWASP's list of critical web security risks
  • A single mistake — MD5 passwords, weak JWT secret, missing HttpOnly flag — can expose every user account
  • Auth bugs are often invisible: the system works but is trivially bypassable
  • Security-in-depth requires both correct concepts and correct implementation

The authentication landscape
MechanismStateBest forMain risk
Session cookiesServer-sideTraditional web apps, SSRCSRF, session fixation
JWT (Bearer)StatelessAPIs, SPA, mobileToken theft, alg=none, weak secret
OAuth2 / OIDCDelegatedThird-party login, SSOOpen redirect, CSRF on callback
API KeysStatelessM2M, developer APIsKey exposure, no expiry
mTLSCert-basedService-to-serviceCert rotation complexity
Phase 3 concept map
  • 1
    Sessions & Cookies — server-side state, HttpOnly+Secure+SameSite, Redis storage, session fixation, CSRF tokens
  • 2
    JWT — header.payload.signature, signing algorithms (HS256/RS256/ES256), claims, verification, pitfalls (alg:none, weak secrets)
  • 3
    Access + Refresh tokens — short-lived access, long-lived refresh, rotation, revocation in DB
  • 4
    OAuth2 — Authorization Code + PKCE, Client Credentials, Device Code; OIDC layer for identity
  • 5
    API Keys — CSPRNG generation, SHA-256 hashing at rest, prefix-based lookup, scoping, rotation
  • 6
    RBAC / ABAC — role hierarchy, permission matrices, policy evaluation, OPA integration
  • 7
    Password security — bcrypt, Argon2id parameters, timing-safe comparison, pepper strategy
Module path: This is M09 in Phase 3 (of 8 phases). Prerequisites: M01 (TCP/TLS), M03 (REST APIs), M06 (SQL/PostgreSQL). Concepts build on each other within this module — read tabs in order on your first pass.
How session-based authentication works
  1. 1
    User submits credentials (username + password) over HTTPS
  2. 2
    Server verifies password hash, then calls session_create() → generates cryptographically random session ID
  3. 3
    Server stores session data (user_id, role, created_at, expires_at) in Redis/DB keyed by session ID
  4. 4
    Server sends: Set-Cookie: sid=<random_id>; HttpOnly; Secure; SameSite=Strict; Max-Age=3600
  5. 5
    Browser automatically includes cookie on every subsequent same-origin request
  6. 6
    Server looks up session ID in Redis → retrieves user context → authorizes request
  7. 7
    On logout: delete the session record from Redis (server-side invalidation) + clear cookie
Cookie attributes — every one matters
AttributePurpose
HttpOnlyJS cannot read cookie → blocks XSS token theft
SecureOnly sent over HTTPS → no cleartext leakage
SameSite=StrictCookie not sent cross-site → blocks CSRF
SameSite=LaxSent on top-level GET nav; blocks form-based CSRF
Max-AgeSeconds until expiry (prefer over Expires)
Path=/Scope to whole domain (usually what you want)
DomainOmit to restrict to exact domain (more secure)
Redis session storage layout
# Key: "session:{id}" — TTL = session duration # Value: hash with user context HSET session:a3f9b2c1... user_id 42 email alice@example.com role admin created_at 1711000000 ip 203.0.113.5 EXPIRE session:a3f9b2c1... 3600 # Lookup on every request (sub-millisecond) HGETALL session:a3f9b2c1...
Use a dedicated Redis DB (index 1+) for sessions, separate from cache, so a cache flush doesn't log everyone out.
Session fixation attack & defence
Attack scenario: Attacker visits site, gets session ID sid=ATTACKER_KNOWN_ID. Tricks victim into using that same ID (e.g., via URL parameter ?sid=...). Victim logs in. Server associates victim's identity with attacker's known session ID. Attacker is now authenticated as victim.
Defence: Always regenerate session ID on privilege elevation On login (or any privilege change): delete old session record, create new session ID, set new cookie. Never reuse a pre-authentication session ID after authentication.
/* Pseudocode for safe login flow */ void handle_login(Request *req, Response *res) { // 1. Verify credentials User *user = verify_credentials(req->body.username, req->body.password); if (!user) { send_401(res); return; } // 2. CRITICAL: destroy old session (session fixation defence) const char *old_sid = get_cookie(req, "sid"); if (old_sid) redis_del(old_sid); // 3. Generate new session ID (128-bit random) uint8_t raw[16]; RAND_bytes(raw, sizeof(raw)); /* OpenSSL CSPRNG */ char sid[33]; bin2hex(raw, sid, 16); // 4. Store in Redis with TTL redis_hset("session:", sid, "user_id", user->id); redis_expire("session:", sid, 3600); // 5. Set HttpOnly Secure SameSite=Strict cookie set_cookie(res, "sid", sid, "HttpOnly; Secure; SameSite=Strict; Max-Age=3600"); }
CSRF — Cross-Site Request Forgery
Why cookies are vulnerable to CSRF: The browser attaches cookies automatically to ANY request to the target origin — including requests initiated from a malicious third-party site via hidden forms or image tags.
CSRF DefenceHow it worksWhen to use
SameSite=StrictCookie never sent cross-siteBest — use for auth cookies
SameSite=LaxSent on top-level GET nav onlyGood fallback, allows OAuth redirects
CSRF token (synchronizer)Server issues random token, validates on POSTNeeded when SameSite not supported
Double-submit cookieCookie + header must matchStateless CSRF protection
Origin/Referer checkValidate request origin headerDefense-in-depth only
SameSite=Lax still allows CSRF via cross-site top-level navigation (e.g., clicking a link). Use Strict for login flows. If you use both SameSite and CSRF tokens, you get defense-in-depth.
Session security checklist
  • Session ID is ≥128 bits from CSPRNG (OpenSSL RAND_bytes, not rand())
  • Session ID regenerated on every login (session fixation defence)
  • Cookie: HttpOnly + Secure + SameSite=Strict
  • Session TTL enforced server-side (Redis EXPIRE), not just client-side cookie
  • Logout deletes session from Redis (not just clears cookie)
  • Concurrent session limit enforced (revoke old sessions on new login, or cap at N)
  • Session ID not in URL (prevents log leakage)
JWT anatomy — every byte matters
A JWT is three Base64url-encoded JSON objects joined by dots:
// Full JWT (line-wrapped for readability): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 .eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxMTAwMDAwMCwiZXhwIjoxNzExMDAzNjAwfQ .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // Header (decoded): { "alg": "HS256", "typ": "JWT" } // Payload (decoded): { "sub": "42", // subject — user ID "iss": "api.example.com", // issuer "aud": "frontend", // audience — who can use this token "role": "admin", // custom claim "iat": 1711000000, // issued at (Unix timestamp) "exp": 1711003600, // expiry — 1 hour later "jti": "a3f9b2c1-..." // JWT ID — for revocation } // Signature = HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)
The payload is not encrypted — only signed. Anyone with the token can base64-decode and read the claims. Never put secrets, PII, or sensitive data in JWT payload unless using JWE (JSON Web Encryption).
Signing algorithms: HS256 vs RS256 vs ES256
AlgorithmTypeKey materialVerify costBest for
HS256Symmetric HMAC-SHA256 One shared secret — all services that verify must have it Very fast Monolith or single-service systems
RS256Asymmetric RSA-PKCS1v15 Private key signs, public key verifies — distributable JWKS endpoint Slow (RSA) Multi-service; public key can be published
ES256Asymmetric ECDSA P-256 Same as RS256 but smaller keys (256-bit vs 2048-bit) Moderate Modern APIs, mobile, IoT
HS256 secret requirements: Must be ≥256 bits (32 bytes) of entropy from CSPRNG. A weak secret (e.g., "secret", "password") can be brute-forced offline — attacker just needs any valid JWT. For RS256/ES256, use a proper key pair generated with OpenSSL.
Access + Refresh token pattern
Problem: If you make access tokens long-lived (24h+), a stolen token is valid for a long time. If you make them short-lived (15min), users must re-login constantly. The solution is two tokens with different lifetimes.
Client Auth Server Resource Server │ │ │ │──POST /login──────►│ │ │ │ verify password │ │◄──access(15m)──────│ │ │ refresh(30d) │ store refresh in DB │ │ │ │ │──GET /api ─────────────────────────────────────────────► │ Authorization: Bearer <access_token> │ │ │ verify signature (no DB lookup) │ │◄────────────────────────────────────── 200 OK ─────────│ │ │ │ │ ... 15 min later, access_token expired ... │ │──POST /refresh────►│ │ │ {refresh_token} │ lookup in DB, validate │ │ │ rotate: delete old, issue new │ │◄──new access(15m)──│ │ │ new refresh(30d) │ │
PropertyAccess TokenRefresh Token
Lifetime15 min – 1 hour7 – 30 days
Storage (client)Memory (SPA) or HttpOnly cookieHttpOnly Secure cookie
ValidationSignature only — no DB lookupDB lookup — can be revoked
RotationNot rotatedSingle-use: new token on each use
On theft detectionWait for expiryRevoke entire refresh token family
Refresh token rotation: if server receives a previously-used (already-rotated) refresh token, assume token theft → revoke all tokens for that user/session immediately (refresh token reuse detection).
Critical JWT vulnerabilities
AttackHow it worksDefence
alg:none Attacker sets "alg":"none" in header and removes signature. Some libraries accept unsigned tokens. Hardcode expected algorithm — never trust the header's alg field. Reject none.
Algorithm confusion RS256 server given token with alg:HS256; HMAC key = public key (which attacker knows). Library uses public key as HMAC secret. Always specify algorithm explicitly in verification call, never pass allowed_algs=all.
Weak HS256 secret Offline brute-force with hashcat using any valid JWT. Use ≥256 bits from CSPRNG. Rotate regularly.
Missing exp validation Expired tokens accepted indefinitely. Always validate exp, nbf, iss, aud.
JWT stored in localStorage XSS can read localStorage and exfiltrate the token. Store in memory (SPA) or HttpOnly cookie. Never localStorage.
JWT revocation strategies
JWTs are stateless — once issued, they're valid until exp unless you implement revocation:
StrategyHowCostScale
Short expiry15-min access tokens; only refresh revocableNoneExcellent
jti denylistStore revoked jti values in Redis; check on every request1 Redis lookup/reqGood
Token family in DBStore token generation counter per user; reject if stale1 DB lookup/reqModerate
JWKS key rotationRotate signing key; old tokens signed with revoked key rejectedNone per-reqExcellent (bulk revoke)
OAuth2 — delegation, not authentication
OAuth2 is an authorization framework, not an authentication protocol. It lets a user delegate limited access to their resources (e.g., their GitHub repos) to a third-party app, without giving the app their password. OpenID Connect (OIDC) adds an identity layer on top of OAuth2.
RoleWho they areExample
Resource OwnerThe userAlice
ClientApp requesting accessYour app
Authorization ServerIssues tokens after user consentGitHub, Google, Auth0
Resource ServerAPI that accepts tokensGitHub API
Authorization Code + PKCE flow (recommended for SPAs & mobile)
User Your App (Client) Auth Server │ │ │ │─click "Login"───►│ │ │ │ generate code_verifier (random 32 bytes) │ │ code_challenge = BASE64URL(SHA-256(verifier)) │ │──redirect /authorize?──────►│ │ │ client_id=... │ │ │ redirect_uri=... │ │ │ response_type=code │ │ │ code_challenge=... │ │ │ code_challenge_method=S256 │ │◄─────────────────│ (user sees consent screen) │ │─approve──────────────────────────────────────►│ │◄──redirect ?code=AUTH_CODE─────────────────────│ │ │ │ │──code────────────►│ │ │ │──POST /token ───────────────►│ │ │ code=AUTH_CODE │ │ │ code_verifier=VERIFIER │ │ │◄───── access_token + id_token│ │◄──logged in──────│ │
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. Even if the code is stolen in transit, the attacker doesn't have the code_verifier needed to exchange it for a token.
Client Credentials flow (machine-to-machine)
Used when there's no user involved — a service authenticating to another service:
// POST /oauth/token { "grant_type": "client_credentials", "client_id": "my-service", "client_secret": "s3cr3t", "scope": "payments:read inventory:write" } // Response: { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "scope": "payments:read inventory:write" }
Client secrets are like passwords — store in environment variables or a secrets manager (Vault, AWS Secrets Manager), never in code or git.

API Keys — design for security
  • 1
    Generation: 32 bytes from CSPRNG → base62 or hex encode → prefix with identifier (e.g., sk_live_ for lookup without full hash scan)
  • 2
    Storage: Never store plaintext. Store SHA-256(key) in DB. Show full key to user exactly once on creation.
  • 3
    Lookup: prefix column (first 8 chars) for fast DB lookup + constant-time comparison of hash
  • 4
    Scoping: Attach permissions to key (e.g., read:payments, write:orders)
  • 5
    Rotation: Allow multiple active keys; deactivate old key after grace period
  • 6
    Rate limiting: Limit by key, not just IP — prevents key sharing abuse
-- API key DB schema CREATE TABLE api_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id BIGINT REFERENCES users(id), prefix VARCHAR(8) NOT NULL, -- first 8 chars, for lookup key_hash CHAR(64) NOT NULL UNIQUE, -- SHA-256 hex name TEXT NOT NULL, -- e.g., "Production App" scopes TEXT[] NOT NULL DEFAULT '{}', last_used TIMESTAMPTZ, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX ON api_keys (prefix) WHERE revoked_at IS NULL;
OAuth2 security pitfalls
VulnerabilityDescriptionDefence
Open redirectredirect_uri not validated → tokens sent to attackerExact match against allowlist of registered URIs
CSRF on callbackAttacker initiates OAuth flow, tricks user's browser to complete itUse state parameter (random nonce, validated on callback)
Token leakage in logsAccess tokens appear in access logs via URL paramsAlways use Authorization header, never URL params
Implicit flowToken returned in URL fragment (deprecated) → history/referrer leakageUse Authorization Code + PKCE instead of Implicit flow
aud not validatedToken issued for service A accepted by service BAlways validate aud claim matches expected audience
RBAC — Role-Based Access Control
Concept: Users are assigned roles; roles are granted permissions. The user never holds permissions directly — only via roles. Simple, auditable, scales well for most apps.
-- Classic RBAC schema CREATE TABLE roles (id BIGINT PRIMARY KEY, name TEXT UNIQUE); CREATE TABLE permissions (id BIGINT PRIMARY KEY, name TEXT UNIQUE); CREATE TABLE role_permissions (role_id BIGINT, permission_id BIGINT, PRIMARY KEY(role_id, permission_id)); CREATE TABLE user_roles (user_id BIGINT, role_id BIGINT, PRIMARY KEY(user_id, role_id)); -- Check: can user 42 read invoices? SELECT 1 FROM user_roles ur JOIN role_permissions rp ON ur.role_id = rp.role_id JOIN permissions p ON rp.permission_id = p.id WHERE ur.user_id = 42 AND p.name = 'invoices:read' LIMIT 1;
RolePermissions
viewerinvoices:read, orders:read
editor+ invoices:write, orders:write
admin+ users:manage, settings:write
billinginvoices:*, payments:*
ABAC — Attribute-Based Access Control
Concept: Policies evaluate attributes of subject (user), resource, action, and environment. More expressive than RBAC — supports fine-grained, context-aware rules.
// OPA (Open Policy Agent) Rego policy example package authz // Allow read if user is in same org as document allow { input.action == "read" input.subject.org_id == input.resource.org_id } // Allow write only for managers in same org allow { input.action == "write" input.subject.role == "manager" input.subject.org_id == input.resource.org_id } // Deny after business hours (environment attribute) deny { input.action == "export" hour := time.clock(time.now_ns())[0] hour >= 18 }
RBACABAC
GranularityCoarse (role-level)Fine (any attribute)
ComplexitySimpleComplex (policy management)
Context-awarenessNoneTime, IP, device, location
AuditingEasyHarder (policy explosion)
Use whenClear role hierarchyMulti-tenant, fine-grained rules
Authorization enforcement patterns
PatternHowProblem it solves
Middleware checkAuth middleware runs before route handler; rejects if insufficient roleCoarse-grained: route-level protection
Resource ownershipWHERE user_id = current_user_id in every queryPrevents horizontal privilege escalation (user A reading user B's data)
Policy as codeOPA sidecar or in-process evaluationComplex/dynamic rules; audit trail
Field-level authzStrip sensitive fields from response if requester lacks permissionFine-grained: same resource, different views
Horizontal privilege escalation (IDOR) — the most common authz bug:
GET /invoices/9999 — always check that invoice 9999 belongs to the authenticated user. Never rely only on "authenticated" — always check ownership.
JWT claims as lightweight RBAC
Embed permissions or roles in the JWT to avoid a DB lookup on every request:
{ "sub": "42", "role": "admin", "perms": ["invoices:read", "invoices:write", "users:manage"], "org_id": "acme", "exp": 1711003600 }
Claims in JWT are stale as soon as they're issued. If you revoke a user's role, they retain the old claims until token expiry. Short access token lifetime (15min) limits the stale-data window. For immediate revocation, use a token denylist.
Why fast hashes are catastrophically wrong
HashSpeed on GPUTime to crack 8-char password
MD5~200 billion/secSeconds
SHA-256~20 billion/secMinutes to hours
bcrypt (cost=10)~10,000/secMonths to years
Argon2id (recommended)~1,000/secYears to decades
Why password hashing needs to be slow: A DB breach exposes all hashed passwords. With a fast hash, a GPU cluster can try billions of common passwords per second. A deliberately slow hash (bcrypt, Argon2) forces brute-force to take impractically long, even if the hash is stolen.
bcrypt — the reliable default
/* bcrypt: cost factor controls iteration count (2^cost rounds) */ /* Target: ~100ms hash time on your server hardware */ /* Start at cost=12; benchmark; increase as hardware improves */ /* Format: $2b$12$<22-char salt><31-char hash> */ $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/leHD7dNzT0pLcOHo. /* Key properties: */ /* - Salt is embedded in the hash string (no separate salt column needed) */ /* - Output always 60 chars — use VARCHAR(60) or CHAR(60) */ /* - Cost factor should be re-evaluated every 2 years */ /* - Max input 72 bytes (silently truncates longer passwords) */
bcrypt truncates inputs longer than 72 bytes. If users might have longer passwords, pre-hash with SHA-256 (to compress to 32 bytes) before bcrypt — but use a constant encoding, not just SHA-256 alone.
Argon2id — OWASP recommended
Why Argon2id over bcrypt? Argon2 is memory-hard — it requires large amounts of RAM per hash, which makes GPU/ASIC attacks expensive (GPUs have less RAM per core than CPUs). Argon2id combines Argon2i (side-channel resistance) and Argon2d (GPU resistance).
/* Argon2id parameters (OWASP recommendations for interactive login): */ /* - m = 19456 KB (19 MiB) memory */ /* - t = 2 iterations */ /* - p = 1 parallelism */ /* - tag_len = 32 bytes output */ /* For high-security (allow >500ms): m=65536, t=3, p=4 */ /* libsodium (C library) — preferred over rolling your own: */ #include <sodium.h> char hashed_password[crypto_pwhash_STRBYTES]; /* 128 bytes */ if (crypto_pwhash_str( hashed_password, password, strlen(password), crypto_pwhash_OPSLIMIT_INTERACTIVE, /* 2 ops */ crypto_pwhash_MEMLIMIT_INTERACTIVE /* 64 MiB */ ) != 0) { /* out of memory — return 500 */ } /* Store hashed_password in DB */ /* Verification: */ if (crypto_pwhash_str_verify( hashed_password, password, strlen(password) ) != 0) { /* Wrong password */ }
Timing-safe comparison — critical for security
Timing attack: A naive strcmp() returns early on the first differing byte. An attacker can measure how long the comparison takes to deduce how many bytes of their guess match. With enough measurements, they can recover a secret byte-by-byte.
/* WRONG — leaks timing information */ int verify = strcmp(submitted_hash, stored_hash) == 0; /* CORRECT — constant-time comparison (OpenSSL) */ #include <openssl/crypto.h> int result = CRYPTO_memcmp(computed_hmac, stored_hmac, HMAC_LEN); /* result == 0 means equal — no early exit */ /* Also safe: libsodium's constant-time equal */ #include <sodium.h> int ok = sodium_memcmp(a, b, len); /* 0 = equal */ /* Use constant-time comparison for: */ /* - HMAC verification (JWT, CSRF tokens, webhook signatures) */ /* - API key comparison (though hash-then-compare is better) */ /* Note: For passwords, use crypto_pwhash_str_verify() — which handles timing internally */
When comparing API keys: hash both submitted and stored values with SHA-256, then compare the hashes with a constant-time function. This is safer than comparing raw keys.
Pepper — defense in depth for password hashing
A pepper is a secret value mixed into the password hash, stored separately from the DB (e.g., in an env var or secrets manager):
/* Pepper strategy: HMAC password with pepper, then Argon2 */ void hash_password_with_pepper(const char *password, char *out) { uint8_t pepper[32]; get_pepper_from_env(pepper); /* load from secrets manager */ /* HMAC-SHA256(pepper, password) → 32 bytes */ uint8_t peppered[32]; HMAC(EVP_sha256(), pepper, 32, (const unsigned char *)password, strlen(password), peppered, NULL); /* Then Argon2id the peppered value */ crypto_pwhash_str(out, (const char *)peppered, 32, crypto_pwhash_OPSLIMIT_INTERACTIVE, crypto_pwhash_MEMLIMIT_INTERACTIVE); } /* Benefit: even if DB is stolen, attacker also needs the pepper to crack passwords. Pepper rotation requires rehashing all users. */
JWT HS256 sign and verify in C (OpenSSL)
#include <openssl/hmac.h> #include <openssl/evp.h> #include <openssl/rand.h> #include <string.h> #include <stdio.h> #include <stdint.h> #include <time.h> /* Base64url encoding (no padding) */ static const char b64url_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; void base64url_encode(const uint8_t *data, size_t len, char *out) { size_t i = 0, j = 0; for (; i + 2 < len; i += 3) { out[j++] = b64url_chars[(data[i] >> 2) & 0x3F]; out[j++] = b64url_chars[((data[i] & 3) << 4) | (data[i+1] >> 4)]; out[j++] = b64url_chars[((data[i+1] & 0xF) << 2) | (data[i+2] >> 6)]; out[j++] = b64url_chars[data[i+2] & 0x3F]; } if (i < len) { out[j++] = b64url_chars[(data[i] >> 2) & 0x3F]; if (i + 1 == len) { out[j++] = b64url_chars[(data[i] & 3) << 4]; } else { out[j++] = b64url_chars[((data[i] & 3) << 4) | (data[i+1] >> 4)]; out[j++] = b64url_chars[(data[i+1] & 0xF) << 2]; } } out[j] = '\0'; } /* Create JWT: header.payload — returns malloc'd string */ char *jwt_create_hs256(const char *payload_json, const uint8_t *secret, size_t secret_len) { /* Fixed header: {"alg":"HS256","typ":"JWT"} */ const char *hdr_json = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; char hdr_b64[64], pay_b64[512], sig_b64[64]; base64url_encode((const uint8_t *)hdr_json, strlen(hdr_json), hdr_b64); base64url_encode((const uint8_t *)payload_json, strlen(payload_json), pay_b64); /* Signing input: base64url(header) + "." + base64url(payload) */ char signing_input[600]; snprintf(signing_input, sizeof(signing_input), "%s.%s", hdr_b64, pay_b64); /* HMAC-SHA256 */ uint8_t sig[32]; uint32_t sig_len; HMAC(EVP_sha256(), secret, (int)secret_len, (const uint8_t *)signing_input, strlen(signing_input), sig, &sig_len); base64url_encode(sig, sig_len, sig_b64); char *token = malloc(700); snprintf(token, 700, "%s.%s", signing_input, sig_b64); return token; } /* Constant-time comparison */ static int const_time_eq(const uint8_t *a, const uint8_t *b, size_t n) { uint8_t diff = 0; for (size_t i = 0; i < n; i++) diff |= a[i] ^ b[i]; return diff == 0; } /* Verify JWT signature — returns 1 on success, 0 on failure */ int jwt_verify_hs256(const char *token, const uint8_t *secret, size_t secret_len, char **payload_out) { /* Split: find the two dots */ const char *dot1 = strchr(token, '.'); if (!dot1) return 0; const char *dot2 = strchr(dot1 + 1, '.'); if (!dot2) return 0; /* signing_input = everything before last dot */ size_t si_len = dot2 - token; char signing_input[600]; if (si_len >= sizeof(signing_input)) return 0; memcpy(signing_input, token, si_len); signing_input[si_len] = '\0'; /* Recompute HMAC */ uint8_t computed[32]; uint32_t clen; HMAC(EVP_sha256(), secret, (int)secret_len, (const uint8_t *)signing_input, si_len, computed, &clen); /* TODO: base64url-decode the signature portion (dot2+1) and compare */ /* Omitted for brevity — see labs for complete implementation */ *payload_out = (char *)(dot1 + 1); /* base64url(payload) */ return 1; /* simplified: full impl decodes and constant-time compares */ }
API key generation and verification in C
#include <openssl/rand.h> #include <openssl/sha.h> #include <openssl/crypto.h> #include <string.h> #include <stdio.h> /* Generate API key: "sk_live_" + 32 random bytes as hex = 72 chars total */ void generate_api_key(char *out_key, size_t key_len, char *out_prefix, char *out_hash_hex) { uint8_t raw[32]; if (RAND_bytes(raw, 32) != 1) { fprintf(stderr, "RAND_bytes failed\n"); return; } /* Build key string */ char hex[65]; for (int i = 0; i < 32; i++) sprintf(hex + i*2, "%02x", raw[i]); snprintf(out_key, key_len, "sk_live_%s", hex); /* First 8 chars after prefix = prefix for DB lookup */ strncpy(out_prefix, hex, 8); out_prefix[8] = '\0'; /* SHA-256 hash for DB storage */ uint8_t hash[32]; SHA256((const uint8_t *)out_key, strlen(out_key), hash); for (int i = 0; i < 32; i++) sprintf(out_hash_hex + i*2, "%02x", hash[i]); out_hash_hex[64] = '\0'; } /* Verify submitted key against stored hash */ int verify_api_key(const char *submitted_key, const char *stored_hash_hex) { uint8_t computed_hash[32]; SHA256((const uint8_t *)submitted_key, strlen(submitted_key), computed_hash); char computed_hex[65]; for (int i = 0; i < 32; i++) sprintf(computed_hex + i*2, "%02x", computed_hash[i]); computed_hex[64] = '\0'; /* Constant-time comparison — prevent timing attacks */ return CRYPTO_memcmp(computed_hex, stored_hash_hex, 64) == 0; } int main(void) { char key[80], prefix[9], hash_hex[65]; generate_api_key(key, sizeof(key), prefix, hash_hex); printf("API Key (show user ONCE): %s\n", key); printf("Store in DB - prefix: %s\n", prefix); printf("Store in DB - hash: %s\n", hash_hex); printf("Verify: %s\n", verify_api_key(key, hash_hex) ? "OK" : "FAIL"); printf("Verify (wrong): %s\n", verify_api_key("sk_live_wrong", hash_hex) ? "OK" : "FAIL"); return 0; } /* gcc api_key.c -o api_key -lssl -lcrypto */
Argon2id password hashing with libsodium
#include <sodium.h> #include <string.h> #include <stdio.h> /* Hash a password for storage */ int hash_password(const char *password, char *hash_out) { /* hash_out must be crypto_pwhash_STRBYTES (128) bytes */ if (crypto_pwhash_str( hash_out, password, strlen(password), crypto_pwhash_OPSLIMIT_INTERACTIVE, /* 2 ops */ crypto_pwhash_MEMLIMIT_INTERACTIVE /* 64 MiB */ ) != 0) { fprintf(stderr, "hash failed: out of memory\n"); return -1; } return 0; } /* Verify a password against stored hash */ int verify_password(const char *stored_hash, const char *password) { int ret = crypto_pwhash_str_verify( stored_hash, password, strlen(password) ); return ret == 0; /* 0 = match */ } /* Check if hash needs re-hash (cost factor upgraded) */ int needs_rehash(const char *stored_hash) { return crypto_pwhash_str_needs_rehash( stored_hash, crypto_pwhash_OPSLIMIT_INTERACTIVE, crypto_pwhash_MEMLIMIT_INTERACTIVE ); } int main(void) { if (sodium_init() < 0) { fprintf(stderr, "sodium init failed\n"); return 1; } char stored_hash[crypto_pwhash_STRBYTES]; const char *password = "correct-horse-battery-staple"; hash_password(password, stored_hash); printf("Stored: %.50s...\n", stored_hash); printf("Verify correct: %s\n", verify_password(stored_hash, password) ? "OK" : "FAIL"); printf("Verify wrong: %s\n", verify_password(stored_hash, "wrongpassword") ? "OK" : "FAIL"); printf("Needs rehash: %s\n", needs_rehash(stored_hash) ? "yes" : "no"); return 0; } /* gcc argon2_demo.c -o argon2_demo -lsodium */
After a successful login, call needs_rehash() — if true, transparently re-hash the plaintext password (which you have in memory at login time only) and update the DB. This handles algorithm upgrades without forcing password resets.
Session ID generation — CSPRNG in C
#include <openssl/rand.h> #include <stdio.h> #include <string.h> /* Generate 128-bit session ID as 32-char hex string */ int generate_session_id(char *out) /* out: 33+ bytes */ { uint8_t raw[16]; if (RAND_bytes(raw, sizeof(raw)) != 1) return -1; /* CSPRNG failure */ for (int i = 0; i < 16; i++) sprintf(out + i*2, "%02x", raw[i]); out[32] = '\0'; return 0; } int main(void) { char sid[33]; generate_session_id(sid); printf("Session ID: %s\n", sid); return 0; } /* gcc session_id.c -o session_id -lssl -lcrypto */
Never use rand(), srand(time(NULL)), or sequential counters for session IDs. These are predictable. Only use a CSPRNG: RAND_bytes() (OpenSSL), randombytes_buf() (libsodium), or /dev/urandom directly.
Lab 1 — JWT from scratch in C
Goal: Implement JWT sign, verify, and claim extraction entirely in C using OpenSSL. No external JWT library.
1
Create a file jwt.c. Implement base64url_encode() and base64url_decode().
2
Implement jwt_sign_hs256(payload_json, secret) → token_string. The header is fixed as {"alg":"HS256","typ":"JWT"}.
3
Implement jwt_verify_hs256(token, secret, payload_out) — splits on dots, recomputes HMAC, constant-time compares, extracts payload.
4
Implement basic JSON claim extraction: jwt_get_claim(payload, "exp") → string using simple string parsing (no JSON library).
5
Test: sign a token with exp = now + 60. Verify it passes. Modify one byte of the token. Verify it fails. Advance time past expiry. Verify expiry check fails.
6
Security test: craft a token with "alg":"none" and no signature. Ensure your verifier rejects it.
Build:
gcc -Wall -Wextra jwt.c -o jwt -lssl -lcrypto ./jwt
Decode any token at jwt.io to verify your base64url encoding is correct.
Lab 2 — Password hashing benchmark and upgrade path
Goal: Understand real-world performance of bcrypt vs Argon2id; implement transparent rehash-on-login.
1
Install libsodium: sudo apt install libsodium-dev
2
Write a benchmark that hashes "password123" 10 times each with:
  • SHA-256 (baseline — show why it's wrong)
  • Argon2id INTERACTIVE params
  • Argon2id MODERATE params
  • Argon2id SENSITIVE params
Print average time per hash.
3
Simulate a "DB" (array of structs) with 5 users. Hash their passwords with Argon2id INTERACTIVE params and store.
4
Simulate login: given username + plaintext password, verify and check needs_rehash().
5
Upgrade: change the params to MODERATE. Re-run login loop — show that users whose hashes used old params get transparently re-hashed on next login.
Build:
gcc -Wall pwhash_bench.c -o pwhash_bench -lsodium ./pwhash_bench
Lab 3 — API key system with PostgreSQL
Goal: Build a complete API key issuance and verification system backed by PostgreSQL and libpq.
1
Create the schema from the OAuth2 tab (api_keys table). Run migrations with psql.
2
Write apikey_issue(user_id, name, scopes[]) → key_string:
  • Generate key: sk_test_ + 32 random bytes hex
  • Compute SHA-256 hash
  • Insert (prefix, hash, user_id, name, scopes) into api_keys
  • Return the full key string (only time it's visible)
3
Write apikey_verify(submitted_key) → {user_id, scopes} or NULL:
  • Extract prefix (first 8 chars after sk_test_)
  • Query: SELECT ... FROM api_keys WHERE prefix=$1 AND revoked_at IS NULL
  • Compute hash of submitted key, constant-time compare with stored hash
  • Update last_used timestamp
  • Return user context on match
4
Write apikey_revoke(key_id) — sets revoked_at = now().
5
Test: issue key, verify it works, verify a wrong key fails, revoke key, verify it's rejected.
Build:
gcc apikey.c -o apikey -lssl -lcrypto -lpq ./apikey
Lab 4 — RBAC middleware in a minimal HTTP server
Goal: Add JWT authentication and RBAC authorization middleware to a minimal HTTP server.
1
Start with a minimal C HTTP server (see M03 reference) with routes: POST /login, GET /api/invoices, DELETE /api/invoices/:id, GET /api/admin/users.
2
Implement POST /login: accepts JSON {username, password}. Verifies against hardcoded users (Argon2id hashes). Returns JWT with sub, role, exp=now+900 (15 min).
3
Implement JWT middleware: extracts Authorization: Bearer <token> header, verifies signature, checks exp, populates request context with user_id and role. Returns 401 if missing/invalid.
4
Add RBAC checks:
  • GET /api/invoices — roles: viewer, editor, admin
  • DELETE /api/invoices/:id — roles: editor, admin only
  • GET /api/admin/users — role: admin only → returns 403 for others
5
Test with curl:
TOKEN=$(curl -s -X POST localhost:8080/login \ -H 'Content-Type: application/json' \ -d '{"username":"alice","password":"hunter2"}' | jq -r .token) curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/invoices curl -X DELETE -H "Authorization: Bearer $TOKEN" localhost:8080/api/invoices/1 curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/admin/users # expect 403
6
Bonus: Add POST /refresh with a refresh token (store in Redis-like in-memory map), implement rotation and reuse detection.
Phase 3 concept checklist

Check each item after you can explain it clearly and implement it without referencing notes.

  • Session ID is ≥128-bit CSPRNG output, hex or base64url encoded, never predictable
  • Session ID regenerated on login — prevents session fixation attacks
  • Session stored server-side (Redis) with TTL; client only holds the ID
  • Cookie attributes: HttpOnly (no JS access), Secure (HTTPS only), SameSite=Strict (no CSRF)
  • Logout deletes session from Redis, doesn't just expire the cookie
  • CSRF: SameSite=Strict prevents cross-site request forgery for modern browsers
  • JWT = base64url(header) + "." + base64url(payload) + "." + base64url(signature)
  • Payload is NOT encrypted — only signed. Never store secrets in payload.
  • Standard claims: iss, sub, aud, exp, nbf, iat, jti
  • HS256 = symmetric HMAC; RS256/ES256 = asymmetric; hardcode algorithm in verifier
  • Always verify: signature, exp, nbf, iss, aud
  • Never accept alg:none — explicitly reject it in your verifier
  • Access token: 15min, stateless. Refresh token: 7-30d, stored in DB for revocation
  • Refresh token rotation: each use issues a new token; reuse detection revokes family
  • OAuth2 = authorization framework (delegation), not authentication
  • PKCE: code_verifier → SHA-256 → code_challenge; prevents authorization code interception
  • State parameter on OAuth callback prevents CSRF
  • redirect_uri must exactly match registered URI (open redirect prevention)
  • API keys: generate with CSPRNG, store SHA-256 hash in DB, show plaintext once
  • API key lookup: prefix column (fast) + constant-time hash comparison
  • RBAC: users → roles → permissions; simple, auditable, works for most systems
  • ABAC: policy evaluates subject + resource + action + environment attributes
  • Always check resource ownership (IDOR prevention): WHERE user_id = $current
  • JWT role/permission claims go stale — short access token TTL limits the window
  • Never use MD5/SHA-256 for passwords — they're too fast (>billion/sec on GPU)
  • Argon2id (OWASP recommended): memory-hard + time-hard + side-channel resistant
  • bcrypt: cost factor 12+, ~100ms target; max 72 bytes input
  • Use constant-time comparison for all security tokens (CRYPTO_memcmp / sodium_memcmp)
  • Implement transparent rehash-on-login for cost factor upgrades
  • Pepper = secret mixed in before hashing; stored in secrets manager, not DB
Common mistakes to avoid
MistakeConsequenceCorrect approach
JWT in localStorageXSS can steal token → full account takeoverMemory (SPA) or HttpOnly cookie
Missing exp checkExpired tokens valid foreverAlways validate all standard claims
Trusting alg headeralg:none bypass, algorithm confusionHardcode expected algorithm
Weak HS256 secretOffline brute-force from any valid token32+ bytes from CSPRNG
MD5/SHA-256 for passwordsFull crack in hours after DB breachArgon2id or bcrypt
Non-constant-time compareTiming oracle reveals token byte-by-byteCRYPTO_memcmp / sodium_memcmp
No session fixation protectionAttacker elevates pre-auth sessionRegenerate session ID on login
Missing IDOR checkUser A reads/writes user B's dataAlways scope queries to current user
← M06 SQL & Indexing ↑ Back to Roadmap M11 Concurrency →