Security Controls
This document details the security measures implemented in Aero2 at the code level.
Summary
| Category | Control | Files |
|---|---|---|
| Authentication | Token signature verification, type enforcement, RS256 only, clock tolerance | token.ts, auth.ts |
| CSRF Protection | OAuth state validation, cookie binding, Origin/Referer checking, SameSite cookies | rp.ts, op.ts, index.ts |
| OIDC Compliance | ID token issuance, PKCE required (S256), nonce support | op.ts |
| Token Security | Refresh token HMAC hashing, one-time auth codes, atomic state consumption | op.ts, crypto.ts, rp.ts |
| Access Control | RBAC system, admin endpoint protection, bootstrap admin | auth.ts, rbac.ts |
| Input Validation | SSRF protection for IdP URLs, open redirect prevention | idp.ts, rp.ts |
| Cryptography | PBKDF2 secret hashing (100k iterations), key rotation with bounds | crypto.ts, jwks.ts |
| Transport | HSTS, CSP, Permissions-Policy, secure cookie attributes | index.ts, rp.ts |
Token Security
Signature Verification
All endpoints verify JWT signatures against the JWKS before trusting claims:
const result = await jose.jwtVerify(token, keySet, {
issuer: getIssuer(env),
audience: getAudience(env),
algorithms: ["RS256"],
clockTolerance: 30,
requiredClaims: ["sub", "iat", "exp"],
});- Algorithm restriction — Only RS256 accepted, preventing algorithm confusion attacks
- Clock tolerance — 30 seconds for distributed system clock skew
- Required claims —
sub,iat,expmust be present
Token Type Enforcement
Prevents token confusion attacks by validating the token_use claim:
- Session tokens:
token_use: "session" - Access tokens:
token_use: "access" - ID tokens:
token_use: "id"
Refresh Token Hashing
Refresh tokens are HMAC-hashed before storage. Database compromise doesn't expose usable tokens:
const hashedRefreshToken = await hmacHash(rawRefreshToken, env.MASTER_KEY);CSRF Protection
OAuth State Validation
State tokens are stored in the database with expiration and consumed atomically on callback:
DELETE FROM oauth_state
WHERE state = ? AND idp_name = ? AND expires_at > datetime('now')
RETURNING redirect_uriState Cookie Binding
State is bound to an HttpOnly cookie to prevent login CSRF:
setCookie(c, "oauth_state", state, {
httpOnly: true,
secure: isSecure,
sameSite: "Lax",
maxAge: 600,
});Origin/Referer Checking
Cookie-authenticated POST/PUT/DELETE/PATCH requests must include a valid Origin or Referer header. Requests missing both headers with a session cookie are rejected with 403. Requests using Bearer token authentication are exempt (not vulnerable to CSRF).
OIDC Compliance
PKCE Required
PKCE with S256 is mandatory for all authorization requests. Plain method is not supported.
ID Token Issuance
When openid scope is requested, an ID token is issued with proper claims including sub, auth_time, and nonce.
Content Security Policy
API/OAuth2 Endpoints
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'SPA HTML Pages
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'Permissions-Policy
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()CORS Configuration
- Production — Requires
ALLOWED_ORIGINSenv var. Rejects all cross-origin requests if not set (fail-closed). - Development — Allows all origins when
ISSUERis not HTTPS. - Origins are parsed from comma-separated list and matched exactly.
credentials: falseon OAuth2 endpoints.
SSRF Protection
IdP URL configuration validates all endpoints:
- Must be HTTPS
- Blocks private IPs (10.x, 172.16-31.x, 192.168.x, 127.x, ::1)
- Blocks localhost (unless
ALLOW_LOCALHOST_IDP=truefor dev)
Access Control (RBAC)
Two default roles with granular permissions:
| Role | Permissions |
|---|---|
admin | clients:*, users:*, idps:*, roles:*, audit:* |
user | users |
Bootstrap Admin
First user matching BOOTSTRAP_ADMIN_EMAIL with verified email gets admin role automatically.
Cryptography
Secret Hashing
Client secrets use PBKDF2 with 100,000 iterations, 16-byte random salt, SHA-256, and constant-time comparison.
Key Rotation
JWKS keys rotate automatically with a Durable Object managing the rotation cycle. Storage is bounded to MAX_KEYS=3 to prevent unbounded growth.
JWKS Caching
- JWKS endpoint:
Cache-Control: public, max-age=3600with ETag support - Individual key endpoint:
Cache-Control: public, max-age=600 - Conditional requests supported via
If-None-Match→ 304 responses
Transport Security
- HSTS —
Strict-Transport-Security: max-age=31536000; includeSubDomains - Cookies —
HttpOnly,Secure,SameSite=Strictfor session cookies - X-Frame-Options —
DENY - X-Content-Type-Options —
nosniff - Referrer-Policy —
strict-origin-when-cross-origin
Session Security
- Sessions tracked in database with user agent and IP
- Individual or bulk revocation via API
- Proactive invalidation — disabling a user immediately revokes all active sessions
- Disabled user check runs before every authenticated action
Open Redirect Prevention
All redirect URIs are validated by validateRedirectUri() utility:
- Only same-origin paths allowed (must start with
/) - Protocol-relative URLs rejected
- External URLs rejected
- Falls back to
/dashboardon invalid input
Configuration
Required Secrets
| Secret | Purpose |
|---|---|
MASTER_KEY | HMAC key for token hashing, encryption |
BOOTSTRAP_ADMIN_EMAIL | First admin user (optional) |
Environment Variables
| Variable | Purpose |
|---|---|
ISSUER | Token issuer claim |
AUDIENCE | Token audience claim |
ALLOWED_ORIGINS | Comma-separated CORS origins (required in production) |
Security Testing Checklist
- Access protected endpoints with forged/modified JWTs
- Attempt HS256-signed tokens (should reject)
- Use access token as session token (should reject)
- Attempt callback with invalid/reused/expired state
- Attempt callback without state cookie
- Test external URLs in redirect_uri
- Configure IdP with private IP addresses (should reject)
- Access admin endpoints without authentication
- Access admin endpoints as non-admin user
- Attempt authorization without code_challenge
- Attempt to use raw refresh token value from logs
- POST without Origin/Referer with session cookie (should reject)
- POST without Origin/Referer with Bearer token (should succeed)