Comprehensive security baseline for Flarelette JWT Kit across HS512, EdDSA, ECDSA, and RSA profiles.
Flarelette JWT Kit is designed from the ground up to prevent the most common JWT vulnerabilities. This section explains exactly how we mitigate historic attacks.
Vulnerability: Attacker sets alg: "none" in token header, library accepts unsigned tokens.
Our Protection:
- Mode determined by server configuration only — Verification mode (HS512 vs EdDSA/RSA) is chosen exclusively from server environment variables, never from the token header
- Strict algorithm whitelists — Each mode has an explicit whitelist of allowed algorithms:
- HS512 mode:
['HS512']only - EdDSA/ECDSA/RSA mode:
['EdDSA', 'ES256', 'ES384', 'ES512', 'RS256', 'RS384', 'RS512']only
- HS512 mode:
- No
nonealgorithm support — Thenonealgorithm is never included in any whitelist - Token
algtreated as untrusted input — Thealgheader must match the allowed algorithms for the selected mode. Mismatches are rejected.
Code location: src/verify.ts:145-152 (verification with explicit algorithm whitelist)
Vulnerability: Attacker obtains RSA public key, creates HMAC-signed token, library verifies using public key as HMAC secret.
Our Protection:
- Symmetric and asymmetric keys never shared — HS512 and EdDSA/RSA use completely separate code paths
- Configuration conflict detection — Throws error if both
JWT_SECRET(HS512) andJWT_PUBLIC_JWK/JWT_JWKS_*(asymmetric) are configured - Separate verification strategies — Key resolution uses strategy pattern with no code path allowing symmetric key to be used for asymmetric verification
Code location: src/config.ts:36-51 (mode conflict detection)
// SECURITY: Detect conflicting configuration
if (hasHS512 && hasAsymmetric) {
throw new Error(
'Configuration error: Both HS512 (JWT_SECRET) and asymmetric (JWT_PUBLIC_JWK/JWT_JWKS_*) secrets configured. Choose one to prevent algorithm confusion attacks.'
)
}Vulnerability: Token includes jku (JWKS URL) or x5u (X.509 URL) header, attacker points to malicious key server.
Our Protection:
- JWKS URL pinned in server configuration —
JWT_JWKS_URLis set in environment variables, never read from token headers - No
jku/x5uheader support — These headers are completely ignored by the library - Service binding JWKS — For Cloudflare Workers, JWKS is fetched via direct Worker-to-Worker RPC (no external URLs)
Code location: src/verify.ts:102-114 (HTTP JWKS with config-only URL)
Vulnerability: Attacker manipulates kid header to perform SQL injection, path traversal, or SSRF.
Our Protection:
kidtreated as pure lookup key — Used only for array/map lookups, never interpolated into SQL, file paths, or URLs- JWKS array searched by equality —
kidcompared using strict equality (===), no string concatenation or interpolation
Code location: src/jwks.ts:219 (kid lookup with strict equality)
const jwk = jwks.find(k => k.kid === kid) // Safe: no interpolationVulnerability: Short HMAC secrets vulnerable to brute force attacks.
Our Protection:
- 64-byte minimum enforced — HS512 requires exactly 64 bytes (512 bits), matching SHA-512 digest size
- Fail-fast on short secrets — Configuration with secrets < 64 bytes throws explicit error with remediation instructions
- CLI tool for secure generation —
npx flarelette-jwt-secret --len=64generates cryptographically random secrets
Code location: src/config.ts:104-109, src/explicit.ts:139-142
// SECURITY: HS512 requires 64-byte minimum (SHA-512 digest size)
if (buf.length < 64) {
throw new Error(
`JWT secret too short: ${buf.length} bytes, need >= 64 for HS512 (use 'npx flarelette-jwt-secret --len=64')`
)
}Vulnerability: Library allows both HS512 and asymmetric configuration simultaneously, creating unpredictable behavior.
Our Protection:
- Single-mode enforcement — Configuration error thrown if both symmetric and asymmetric secrets detected
- Explicit mode detection — Mode determined once at startup based on environment variables
Code location: src/config.ts:47-51
When importing JWKs, the expected algorithm is provided explicitly to the jose library:
// Inline JWK import — EdDSA requires explicit algorithm hint; EC/RSA auto-detected
const key =
jwk.kty === 'OKP'
? await importJWK(jwk, 'EdDSA') // OKP keys need explicit algorithm
: await importJWK(jwk) // EC/RSA keys: jose auto-detects from kty+crvThe algorithm whitelist in jwtVerify() provides the primary protection (only whitelisted algorithms accepted). Explicit algorithm specification at import time adds a second layer of defense.
Code location: src/verify.ts:71-73
Pattern: All verification failures return null to callers (never throw exceptions).
Rationale:
- Simplifies error handling in HTTP request handlers
- Prevents information leakage via error messages
- Consistent interface for all failure modes
Observability: While the library returns null for all failures, applications should log verification failures with structured metadata:
const payload = await verify(token)
if (!payload) {
// Log failure with context (but not the token itself)
console.warn({
event: 'jwt_verification_failed',
iss: config.iss, // Expected issuer
aud: config.aud, // Expected audience
// DO NOT log the actual token
})
return new Response('Unauthorized', { status: 401 })
}Recommendation: Track verification failure rates in metrics/APM for anomaly detection.
The kit supports four JWT algorithm profiles by design. Each has specific security properties and use cases.
| Property | Value |
|---|---|
| Algorithm | HMAC-SHA-512 |
| Key material | 64-byte base64url secret |
| Security level | ~256-bit |
| Key distribution | Shared secret between producer and consumer |
| Use case | Internal trusted services with shared secret |
Security properties:
- Fast signing and verification
- Simple key management (single shared secret)
- No public key distribution needed
- Requires mutual trust between producer and consumer
When to use:
- Both producer and consumer are trusted services
- Services can securely share a secret
- Simplest deployment with no key rotation requirements
| Property | Value |
|---|---|
| Algorithm | Ed25519 digital signature |
| Key material | 32-byte private key + public key (JSON Web Keys) |
| Security level | ~128-bit (quantum-safe path exists) |
| Key distribution | Public key distributed via JWKS or inline |
| Use case | One-way trust, public verification, key rotation |
Security properties:
- Asymmetric: private key signs, public key verifies
- Public key can be distributed safely
- Supports key rotation via
kidheader - Resistant to timing attacks
When to use:
- Gateway signs, multiple services verify
- Key rotation required (multiple active keys)
- Zero-trust architecture with distributed services
- Public verification needed (consumers don't need signing capability)
| Property | Value |
|---|---|
| Algorithm | ECDSA with P-256, P-384, or P-521 curves |
| Key material | EC private/public key pair (JSON Web Keys) |
| Security level | 128–256-bit depending on curve |
| Key distribution | Public key distributed via JWKS or inline |
| Use case | Self-hosted OIDC gateway (ES512); external OIDC verification |
Security properties:
- Asymmetric: private key signs, public key verifies
- ES512 (P-521) signing available via TypeScript explicit API (
createES512SignConfig) - Verification supports tokens from OIDC providers using any ECDSA curve
- jose auto-detects EC algorithm from JWK
kty/crvfields at import
When to use:
- Verifying tokens from OIDC providers that use ECDSA (ES256 is common; ES512 is FIPS-preferred)
- Self-hosted OIDC gateway that must sign with P-521 for compliance
- When standards requirements mandate ECDSA over EdDSA
Note: ECDSA signing is TypeScript explicit API only. Environment-driven mode detection (JWT_PRIVATE_JWK*) triggers EdDSA, not ECDSA.
Requirements:
- Minimum 64 bytes (512 bits)
- Cryptographically random
- Base64url-encoded for safe storage
Generate with CLI:
npx flarelette-jwt-secret --len=64 --dotenvOutput:
JWT_SECRET=<64-byte-base64url-string>Generate programmatically:
TypeScript:
import { generateSecret } from '@chrislyons-dev/flarelette-jwt'
const secret = generateSecret(64)
console.log(`JWT_SECRET=${secret}`)Python:
from flarelette_jwt import generate_secret
secret = generate_secret(64)
print(f"JWT_SECRET={secret}")The flarelette-jwt-keygen CLI generates EdDSA (Ed25519) or ECDSA (P-256/P-384/P-521) keypairs. EdDSA is the default.
Flags:
| Flag | Description | Default |
|---|---|---|
--alg=<alg> |
Algorithm: EdDSA, ES256, ES384, ES512 |
EdDSA |
--kid=<id> |
Key ID to embed in JWK | <alg>-<timestamp> |
--dotenv |
Output as .env JWT_* variable assignments |
JSON object |
Generate EdDSA keypair (default — recommended for new deployments):
# Ed25519: fast, compact signatures, strong security. Preferred for new internal services.
npx flarelette-jwt-keygen --kid=ed25519-2025-01Generate ES512 keypair (ECDSA P-521 — use when FIPS or ECDSA compatibility is required):
# ES512: FIPS-compliant ECDSA P-521. Use when standards mandate ECDSA over EdDSA.
npx flarelette-jwt-keygen --alg=ES512 --kid=es512-2025-01Generate as .env assignments for direct use:
npx flarelette-jwt-keygen --alg=EdDSA --dotenv
# Outputs:
# JWT_PUBLIC_JWK='{"kty":"OKP","crv":"Ed25519",...}'
# JWT_PRIVATE_JWK='{"kty":"OKP","crv":"Ed25519","d":"...",...}'JSON output format:
{
"publicJwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": "<base64url-public-key>",
"kid": "ed25519-2025-01",
"alg": "EdDSA",
"use": "sig"
},
"privateJwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": "<base64url-public-key>",
"d": "<base64url-private-key>",
"kid": "ed25519-2025-01"
}
}Best practice for production:
- Generate keys during deployment CI (ephemeral keys no human ever sees)
- Store private key in secret binding immediately
- Distribute public key via JWKS or environment binding
❌ Never do this:
# wrangler.toml - DON'T COMMIT THIS
[vars]
JWT_SECRET = "actual-secret-value" # ❌ Exposed in version control✅ Use secret-name indirection:
# wrangler.toml - Safe to commit
[vars]
JWT_SECRET_NAME = "MY_JWT_SECRET" # References binding, not value
JWT_ISS = "https://gateway.example.com"
JWT_AUD = "api.example.com"# Deploy secret separately
wrangler secret put MY_JWT_SECRET
# Paste secret when promptedUse different secret bindings for each environment.
# wrangler.dev.toml
[vars]
JWT_SECRET_NAME = "JWT_SECRET_DEV"
# wrangler.staging.toml
[vars]
JWT_SECRET_NAME = "JWT_SECRET_STAGING"
# wrangler.production.toml
[vars]
JWT_SECRET_NAME = "JWT_SECRET_PROD"Deploy secrets to each environment:
wrangler secret put JWT_SECRET_DEV --env dev
wrangler secret put JWT_SECRET_STAGING --env staging
wrangler secret put JWT_SECRET_PROD --env productionProduction (Service Binding - Recommended):
- Deploy JWT gateway with JWKS endpoint and public key
- Configure consumer workers with service binding
- Keys fetched via direct Worker-to-Worker RPC (private, low-latency)
Benefits:
- No public HTTP endpoint required
- Lower latency (direct RPC, no DNS/TLS overhead)
- Better security (private Worker communication only)
- Integrated with Cloudflare routing
Development/Offline (Inline JWK):
- Deploy public key directly to consumer environment
- Configure
JWT_PUBLIC_JWK_NAMEpointing to secret binding - Note: Requires redeployment for key rotation, no JWKS support
Optional: Thumbprint Pinning
For additional security, pin trusted key thumbprints:
JWT_ALLOWED_THUMBPRINTS=abc123def456,789ghi012jklOnly keys matching these thumbprints will be accepted for verification.
Process:
- Generate new secret
- Deploy new secret to producer and all consumers
- Start signing with new secret
- Wait for maximum token TTL (default 15 min)
- Remove old secret
Downtime: None (if consumers support both secrets during transition)
Frequency: Rotate at least every 90 days or immediately on suspicion of compromise.
Process:
- Generate new keypair with new
kid - Publish new JWKS including both old and new public keys
- Update producer to sign with new key
- Allow dual verification during TTL window
- After TTL expires, remove old key from JWKS
Example:
Before rotation (JWKS):
{
"keys": [
{ "kid": "ed25519-2025-01", "kty": "OKP", ... }
]
}During rotation (both keys active):
{
"keys": [
{ "kid": "ed25519-2025-01", "kty": "OKP", ... },
{ "kid": "ed25519-2025-02", "kty": "OKP", ... }
]
}After rotation (old key removed):
{
"keys": [
{ "kid": "ed25519-2025-02", "kty": "OKP", ... }
]
}Benefits:
- Zero downtime
- Consumers automatically fetch new keys
- No consumer redeployment needed
- Full audit trail via
kidheader
These claims are automatically populated:
iss— Token issuer (fromJWT_ISS)aud— Token audience (fromJWT_AUD)iat— Issued at (current timestamp)exp— Expiration (current timestamp + TTL)jti— JWT ID (optional, for replay prevention)
Add custom claims with user identity and authorization:
const token = await sign({
sub: 'user123', // Subject (user ID)
permissions: ['read:data'], // Permission strings
roles: ['user', 'editor'], // Role strings
email: 'user@example.com', // OIDC standard claim
tid: 'tenant-123', // Multi-tenant apps
})Only include claims necessary for authorization decisions. Never include:
- Passwords or password hashes
- Credit card numbers or payment information
- Social security numbers or national IDs
- Full medical records
- Large datasets (keep tokens < 8KB)
Why: Tokens are transmitted with every request and logged in various places. Treat them as semi-public.
Default: 900 seconds (15 minutes)
Recommendation:
- External-facing APIs: 15-60 minutes
- Internal service tokens: 5-15 minutes
- Delegated tokens: 5 minutes
Configure via:
JWT_TTL_SECONDS=300 # 5 minutesOr override per-token:
const token = await createToken({ sub: 'user123' }, { ttlSeconds: 300 })When calling verify() or checkAuth(), these checks are performed:
- Signature verification — Cryptographic signature valid for detected algorithm
- Issuer check —
issmatchesJWT_ISS - Audience check —
audmatchesJWT_AUD - Expiration check — Token not expired (
exp> now - leeway) - Not before check — If
nbfpresent, token is valid (nbf< now + leeway)
Default leeway: 90 seconds
Accounts for:
- Time sync differences between services
- Network latency
- Clock drift
Configure via:
JWT_LEEWAY=120 # 2 minutesOr override per-verification:
const payload = await verify(token, { leeway: 120 })Security consideration: Keep leeway ≤ 90 seconds to avoid excessive expiry drift.
The kit rejects tokens with unexpected alg headers. This prevents algorithm substitution attacks.
Example: If environment detects HS512 mode, EdDSA tokens are rejected (and vice versa).
For APIs requiring replay prevention, store jti in a short-TTL key-value store.
import { checkAuth } from '@chrislyons-dev/flarelette-jwt'
const auth = await checkAuth(token, policy().build())
if (!auth) {
return new Response('Unauthorized', { status: 401 })
}
// Check if token was already used
const jti = auth.payload.jti
if (await kv.get(`used:${jti}`)) {
return new Response('Token already used', { status: 403 })
}
// Mark token as used (expires with token TTL)
await kv.put(`used:${jti}`, 'true', {
expirationTtl: auth.payload.exp - Date.now() / 1000,
})Never transmit tokens over plaintext HTTP. Always use HTTPS/TLS for:
- External API requests
- Internal service-to-service communication
- JWKS endpoint (if not using service bindings)
✅ Correct:
Authorization: Bearer <jwt-token>
❌ Never:
- Query parameters:
?token=<jwt>(logged in access logs, proxy logs, browser history) - Request body:
{"token": "<jwt>"}(unnecessarily verbose) - Cookies: (unless specifically designed for cookie-based auth with CSRF protection)
Never log entire tokens. Log only non-sensitive parts:
✅ Safe to log:
console.log({
jti: payload.jti, // JWT ID
sub: payload.sub, // Subject (user ID)
iss: payload.iss, // Issuer
aud: payload.aud, // Audience
exp: payload.exp, // Expiration
action: 'read:data', // Action performed
})❌ Never log:
console.log(`Token: ${token}`) // ❌ Full token exposed
console.log(`Bearer ${token}`) // ❌ Full token exposedRedact in APM and telemetry:
- Configure log redaction rules for
Authorizationheaders - Use allowlists for logged fields (never log entire objects containing tokens)
Depend on platform time sync:
- Cloudflare Workers: NTP-backed, reliable
- Node.js/Python: Ensure host has NTP configured
Keep leeway ≤ 90 seconds to prevent excessive expiry drift while accounting for:
- Network latency (typically < 1 second)
- Clock drift (NTP keeps this minimal)
- Service restart time skew
Balance:
- Too low: Legitimate tokens rejected due to minor clock differences
- Too high: Expired tokens accepted for too long
joselibrary: Pinned version for cryptographic operations- Review changelogs before upgrading
- Run
npm auditregularly
- Zero external crypto dependencies — uses WebCrypto API directly
- Stdlib only — reduces supply chain risk
Commit lockfiles:
package-lock.json(npm)yarn.lock(Yarn)pyproject.toml(Python)
Benefits:
- Reproducible builds
- Security scanning can detect vulnerable versions
- Prevents unexpected dependency changes
Use Dependabot or Renovate for automated dependency updates:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'weekly'
- package-ecosystem: 'pip'
directory: '/packages/flarelette-jwt-py'
schedule:
interval: 'weekly'Unit tests must cover:
- Signature verification (positive and negative cases)
- Claim validation (
iss,aud,exp,nbfwith leeway) - Authorization logic (permissions, roles, predicates)
- Mode detection (HS512 vs EdDSA based on environment)
- Secret-name indirection (resolution and fallback)
Run these checks in CI:
- ESLint (TypeScript/JavaScript)
- Ruff (Python linting)
- mypy (Python type checking)
- TypeScript compiler (type checking)
Enable secret scanning to prevent committed secrets:
- Gitleaks (open source)
- GitHub Advanced Security (GitHub)
- GitLab Secret Detection (GitLab)
Example Gitleaks config:
# .gitleaks.toml
[[rules]]
id = "jwt-secrets"
description = "JWT secrets and keys"
regex = '''JWT_(SECRET|PRIVATE_JWK|PUBLIC_JWK|JWKS_URL)\s*=\s*["']?[A-Za-z0-9_\-+/={}:,"\.]{32,}["']?'''Before deploying to production:
- Single algorithm mode enforced: HS512 or asymmetric (EdDSA/ECDSA/RSA) — not both in same environment
- Secrets stored as Cloudflare bindings (
*_NAMEpattern) - TTL ≤ 15 minutes; leeway ≤ 90 seconds
-
JWT_AUDis specific per service (no wildcard audiences) — prevents token reuse between services - No tokens in logs, URLs, or version control
- Minimal claims principle applied (no PII unless necessary)
- Rotation policy documented and tested (both HS512 and EdDSA)
- Thumbprint pinning configured (if using EdDSA with strict requirements)
- CI secret scan enabled
- Dependencies pinned in lockfiles
- Incident response runbook prepared
- TLS everywhere (no plaintext transmission)
- Authorization header used (
Authorization: Bearer) - Test coverage includes security-critical paths
Immediate actions:
- Rotate secrets/keys immediately
- Revoke sessions by shortening TTL and reissuing tokens
- Review access logs for suspicious activity
- Notify affected users if PII exposed
Investigation:
- Identify scope of compromise (which secrets, how long exposed)
- Review logs for unusual patterns
- Check for permission escalation attempts
Post-incident:
- Document root cause
- Update security procedures
- Add detection for similar incidents
- Consider additional controls (e.g., replay prevention, stricter TTLs)
Log sufficient context for forensics without logging tokens:
console.log({
timestamp: new Date().toISOString(),
jti: payload.jti, // JWT ID
sub: payload.sub, // Subject
iss: payload.iss, // Issuer
aud: payload.aud, // Audience
iat: payload.iat, // Issued at
exp: payload.exp, // Expiration
actor: payload.act?.sub, // Actor service (if delegated)
action: 'read:sensitive', // Action performed
result: 'success', // Outcome
ip: requestIP, // Client IP (if applicable)
})- Token forgery — Cryptographic signature prevents creating valid tokens without secret/private key
- Algorithm substitution — Kit rejects tokens with unexpected
algheaders - Expired token reuse — Expiration checks with leeway prevent use of expired tokens
- Clock skew exploitation — Leeway limited to 90 seconds by default
- Permission escalation — Delegated tokens preserve original permissions, no escalation
- Replay attacks — Optional
jtitracking in KV store
Token theft:
- If attacker obtains valid token, they can use it until expiration
- Mitigate with: Short TTLs (5-15 min), TLS everywhere, secure storage
Compromised secret/private key:
- Attacker can forge tokens indefinitely
- Mitigate with: Secret rotation, access controls, ephemeral keys, HSM storage
Side-channel attacks:
- Timing attacks on signature verification (EdDSA resistant, HS512 uses constant-time comparisons)
- Mitigate with: Use vetted crypto libraries (
jose, WebCrypto)
Distributed denial of service:
- Signature verification is computationally expensive
- Mitigate with: Rate limiting, WAF rules, valid token caching
- RFC 7519: JSON Web Token (JWT)
- RFC 7517: JSON Web Key (JWK)
- RFC 8693: OAuth 2.0 Token Exchange
- OWASP JWT Cheat Sheet
- Cloudflare Workers Security
Questions or security concerns? Open a security issue or contact the maintainers directly.