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
HttpOnlyflag — 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
| Mechanism | State | Best for | Main risk |
|---|---|---|---|
| Session cookies | Server-side | Traditional web apps, SSR | CSRF, session fixation |
| JWT (Bearer) | Stateless | APIs, SPA, mobile | Token theft, alg=none, weak secret |
| OAuth2 / OIDC | Delegated | Third-party login, SSO | Open redirect, CSRF on callback |
| API Keys | Stateless | M2M, developer APIs | Key exposure, no expiry |
| mTLS | Cert-based | Service-to-service | Cert rotation complexity |
Phase 3 concept map
- 1Sessions & Cookies — server-side state,
HttpOnly+Secure+SameSite, Redis storage, session fixation, CSRF tokens - 2JWT — header.payload.signature, signing algorithms (HS256/RS256/ES256), claims, verification, pitfalls (
alg:none, weak secrets) - 3Access + Refresh tokens — short-lived access, long-lived refresh, rotation, revocation in DB
- 4OAuth2 — Authorization Code + PKCE, Client Credentials, Device Code; OIDC layer for identity
- 5API Keys — CSPRNG generation, SHA-256 hashing at rest, prefix-based lookup, scoping, rotation
- 6RBAC / ABAC — role hierarchy, permission matrices, policy evaluation, OPA integration
- 7Password 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
- 1User submits credentials (username + password) over HTTPS
- 2Server verifies password hash, then calls
session_create()→ generates cryptographically random session ID - 3Server stores session data (user_id, role, created_at, expires_at) in Redis/DB keyed by session ID
- 4Server sends:
Set-Cookie: sid=<random_id>; HttpOnly; Secure; SameSite=Strict; Max-Age=3600 - 5Browser automatically includes cookie on every subsequent same-origin request
- 6Server looks up session ID in Redis → retrieves user context → authorizes request
- 7On logout: delete the session record from Redis (server-side invalidation) + clear cookie
Cookie attributes — every one matters
| Attribute | Purpose |
|---|---|
HttpOnly | JS cannot read cookie → blocks XSS token theft |
Secure | Only sent over HTTPS → no cleartext leakage |
SameSite=Strict | Cookie not sent cross-site → blocks CSRF |
SameSite=Lax | Sent on top-level GET nav; blocks form-based CSRF |
Max-Age | Seconds until expiry (prefer over Expires) |
Path=/ | Scope to whole domain (usually what you want) |
Domain | Omit 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 Defence | How it works | When to use |
|---|---|---|
SameSite=Strict | Cookie never sent cross-site | Best — use for auth cookies |
SameSite=Lax | Sent on top-level GET nav only | Good fallback, allows OAuth redirects |
| CSRF token (synchronizer) | Server issues random token, validates on POST | Needed when SameSite not supported |
| Double-submit cookie | Cookie + header must match | Stateless CSRF protection |
| Origin/Referer check | Validate request origin header | Defense-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, notrand()) - 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
| Algorithm | Type | Key material | Verify cost | Best for |
|---|---|---|---|---|
| HS256 | Symmetric HMAC-SHA256 | One shared secret — all services that verify must have it | Very fast | Monolith or single-service systems |
| RS256 | Asymmetric RSA-PKCS1v15 | Private key signs, public key verifies — distributable JWKS endpoint | Slow (RSA) | Multi-service; public key can be published |
| ES256 | Asymmetric 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) │ │
| Property | Access Token | Refresh Token |
|---|---|---|
| Lifetime | 15 min – 1 hour | 7 – 30 days |
| Storage (client) | Memory (SPA) or HttpOnly cookie | HttpOnly Secure cookie |
| Validation | Signature only — no DB lookup | DB lookup — can be revoked |
| Rotation | Not rotated | Single-use: new token on each use |
| On theft detection | Wait for expiry | Revoke 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
| Attack | How it works | Defence |
|---|---|---|
| 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:
| Strategy | How | Cost | Scale |
|---|---|---|---|
| Short expiry | 15-min access tokens; only refresh revocable | None | Excellent |
| jti denylist | Store revoked jti values in Redis; check on every request | 1 Redis lookup/req | Good |
| Token family in DB | Store token generation counter per user; reject if stale | 1 DB lookup/req | Moderate |
| JWKS key rotation | Rotate signing key; old tokens signed with revoked key rejected | None per-req | Excellent (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.
| Role | Who they are | Example |
|---|---|---|
| Resource Owner | The user | Alice |
| Client | App requesting access | Your app |
| Authorization Server | Issues tokens after user consent | GitHub, Google, Auth0 |
| Resource Server | API that accepts tokens | GitHub 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
- 1Generation: 32 bytes from CSPRNG → base62 or hex encode → prefix with identifier (e.g.,
sk_live_for lookup without full hash scan) - 2Storage: Never store plaintext. Store
SHA-256(key)in DB. Show full key to user exactly once on creation. - 3Lookup:
prefixcolumn (first 8 chars) for fast DB lookup + constant-time comparison of hash - 4Scoping: Attach permissions to key (e.g.,
read:payments,write:orders) - 5Rotation: Allow multiple active keys; deactivate old key after grace period
- 6Rate 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
| Vulnerability | Description | Defence |
|---|---|---|
| Open redirect | redirect_uri not validated → tokens sent to attacker | Exact match against allowlist of registered URIs |
| CSRF on callback | Attacker initiates OAuth flow, tricks user's browser to complete it | Use state parameter (random nonce, validated on callback) |
| Token leakage in logs | Access tokens appear in access logs via URL params | Always use Authorization header, never URL params |
| Implicit flow | Token returned in URL fragment (deprecated) → history/referrer leakage | Use Authorization Code + PKCE instead of Implicit flow |
| aud not validated | Token issued for service A accepted by service B | Always 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;
| Role | Permissions |
|---|---|
| viewer | invoices:read, orders:read |
| editor | + invoices:write, orders:write |
| admin | + users:manage, settings:write |
| billing | invoices:*, 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
}
| RBAC | ABAC | |
|---|---|---|
| Granularity | Coarse (role-level) | Fine (any attribute) |
| Complexity | Simple | Complex (policy management) |
| Context-awareness | None | Time, IP, device, location |
| Auditing | Easy | Harder (policy explosion) |
| Use when | Clear role hierarchy | Multi-tenant, fine-grained rules |
Authorization enforcement patterns
| Pattern | How | Problem it solves |
|---|---|---|
| Middleware check | Auth middleware runs before route handler; rejects if insufficient role | Coarse-grained: route-level protection |
| Resource ownership | WHERE user_id = current_user_id in every query | Prevents horizontal privilege escalation (user A reading user B's data) |
| Policy as code | OPA sidecar or in-process evaluation | Complex/dynamic rules; audit trail |
| Field-level authz | Strip sensitive fields from response if requester lacks permission | Fine-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
| Hash | Speed on GPU | Time to crack 8-char password |
|---|---|---|
| MD5 | ~200 billion/sec | Seconds |
| SHA-256 | ~20 billion/sec | Minutes to hours |
| bcrypt (cost=10) | ~10,000/sec | Months to years |
| Argon2id (recommended) | ~1,000/sec | Years 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
Build:
Security test: craft a token with
"alg":"none" and no signature. Ensure your verifier rejects it.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-dev2
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
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
Build:
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.
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
Build:
Test: issue key, verify it works, verify a wrong key fails, revoke key, verify it's rejected.
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, adminDELETE /api/invoices/:id— roles: editor, admin onlyGET /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.
Sessions & Cookies
- 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
- 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 & API Keys
- 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
Authorization
- 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
Password Security
- 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
| Mistake | Consequence | Correct approach |
|---|---|---|
| JWT in localStorage | XSS can steal token → full account takeover | Memory (SPA) or HttpOnly cookie |
Missing exp check | Expired tokens valid forever | Always validate all standard claims |
Trusting alg header | alg:none bypass, algorithm confusion | Hardcode expected algorithm |
| Weak HS256 secret | Offline brute-force from any valid token | 32+ bytes from CSPRNG |
| MD5/SHA-256 for passwords | Full crack in hours after DB breach | Argon2id or bcrypt |
| Non-constant-time compare | Timing oracle reveals token byte-by-byte | CRYPTO_memcmp / sodium_memcmp |
| No session fixation protection | Attacker elevates pre-auth session | Regenerate session ID on login |
| Missing IDOR check | User A reads/writes user B's data | Always scope queries to current user |