Skip to content

feat(security): JWT secret hardening + dynamic CORS + sliding-window rate limiting#24

Merged
oomokaro1 merged 2 commits into
OrbitStream:mainfrom
playground-ogazboiz:feat/jwt-cors-rate-limiting
Jun 20, 2026
Merged

feat(security): JWT secret hardening + dynamic CORS + sliding-window rate limiting#24
oomokaro1 merged 2 commits into
OrbitStream:mainfrom
playground-ogazboiz:feat/jwt-cors-rate-limiting

Conversation

@ogazboiz

@ogazboiz ogazboiz commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the three security hardening areas from #5: JWT secret management with rotation, dynamic CORS with security headers, and Redis-backed sliding-window rate limiting with graceful degradation. New code matches the existing Nest module/service conventions and lives under src/config/ and src/api/middleware/.

Changes

1. JWT secret management (src/config/jwt-secret.config.ts, src/auth/*)

  • Startup crashes in production when JWT_SECRET is missing, still a placeholder (dev-secret / change-me-in-production), or shorter than 32 characters. Length is enforced in every environment.
  • Zero-downtime rotation via JWT_SECRET_PREVIOUS: tokens signed with the current or previous secret verify. Previous-secret tokens are flagged (viaPreviousSecret) and can be exchanged for a current-secret token via the new POST /auth/refresh (JWT-guarded). All logins already sign with the current secret.
  • JwtModule now resolves the validated secret via registerAsync; the Passport strategy uses a secretOrKeyProvider to pick the matching key.

2. CORS + security headers (src/api/middleware/security.middleware.ts, cors.config.ts)

  • Allowed origins read from CORS_ALLOWED_ORIGINS (comma-separated). Defaults to http://localhost:3000 in dev; required in production (startup crash if unset).
  • Dynamic CORS echoes the matching Origin (never *), sets Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS, Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-Id, Access-Control-Max-Age: 86400, and short-circuits preflight OPTIONS with 204.
  • GET /v1/checkout/sessions/:id is public and accepts any origin.
  • Adds Strict-Transport-Security, X-Content-Type-Options: nosniff, X-Frame-Options: DENY to every response.
  • Replaces the permissive app.enableCors() in main.ts.

3. Sliding-window rate limiting (src/api/middleware/rate-limit.*)

  • Redis sorted sets with an atomic Lua script (drop-expired + count + add + TTL in one round trip) to avoid races.
  • Per-endpoint limits: /auth/login|/auth/verify 5/min per IP; /merchants/register 3/min per IP; POST /v1/checkout/sessions 100/min per API key; GET /v1/checkout/sessions/:id 60/min per IP; all others 60/min per IP.
  • X-RateLimit-Limit / -Remaining / -Reset headers; on breach 429 with Retry-After.
  • Graceful degradation: when Redis is unavailable, falls back to in-memory limiting at 50% of the normal limit and logs a (throttled) warning.
  • /health and /metrics are exempt. Applied globally via RateLimitModule.

Env

  • .env.example updated with explanatory comments for JWT_SECRET, JWT_SECRET_PREVIOUS, and CORS_ALLOWED_ORIGINS.

Test plan

  • npm run build (tsc) passes
  • Unit: JWT secret validation — missing / too short / dev-secret in prod / rotation accept + flag
  • Unit: CORS — valid origin, invalid origin, preflight 204, public route, security headers
  • Unit: rate limiting — within / at / over limit, per-identity isolation, Redis-failure fallback at 50%
  • Integration (supertest): 429 + Retry-After returned once the limit is exceeded; /health exempt; CORS/security headers present; preflight 204
  • 64 new tests pass; full suite: 144 passed
  • Load test (artillery) — config provided at load-tests/rate-limit.artillery.yml, left as a follow-up (see notes)

Notes / out of scope

  • Load testing is provided as an optional Artillery config (load-tests/rate-limit.artillery.yml) runnable via npx artillery — no new dependency added per the issue guidance. Full load verification is left as a follow-up.
  • 3 pre-existing test suites (webhook-controller, webhook-module, app.smoke) fail because src/db/index.ts throws DATABASE_URL is required at import time when no DB env is set. This is unrelated to this PR and predates it; the new code does not import the DB layer.

Closes #5

… limiting

JWT secret management:
- Fail fast on startup when JWT_SECRET is missing, still a placeholder
  (dev-secret/change-me-in-production), or shorter than 32 chars in production
- Support zero-downtime rotation via JWT_SECRET_PREVIOUS: tokens signed with the
  current OR previous secret verify; previous-secret tokens are flagged and can be
  re-issued with the current secret via POST /auth/refresh

CORS + security headers:
- Read allowed origins from CORS_ALLOWED_ORIGINS (comma-separated); default
  http://localhost:3000 in dev, required in production (crash if unset)
- Dynamic CORS echoes the matching Origin (never *), sets the documented
  methods/headers/max-age, and short-circuits preflight OPTIONS with 204
- Public route GET /v1/checkout/sessions/:id accepts any origin
- Add Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options

Sliding-window rate limiting (Redis sorted sets, atomic Lua):
- Per-endpoint limits keyed by IP or API key (auth 5/min, register 3/min,
  checkout create 100/min per key, checkout get 60/min, default 60/min)
- X-RateLimit-Limit/Remaining/Reset headers; 429 + Retry-After on breach
- Graceful degradation to in-memory limiting at 50% when Redis is down
- Exempt /health and /metrics

Tests:
- Unit tests for JWT secret validation, rotation, CORS, rate-limit rules/service
  (ioredis-mock) and Redis-failure fallback
- Integration test (supertest) asserting 429 + headers under limit breach
- Optional artillery load-test config (no new deps; install via npx)

Closes OrbitStream#5

@oomokaro1 oomokaro1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough implementation — the JWT hardening and rate limiting are solid and match the issue spec exactly.

However, there's a critical conflict that needs to be resolved before merging:

CORS changes conflict with already-merged PR #22

PR #22 (merged to main) already implemented a comprehensive dynamic CORS system:

  • src/middleware/dynamic-cors.middleware.ts — Route-grouped CORS with per-merchant origin allowlisting (DB-backed + Redis-cached)
  • src/middleware/cors-origins-cache.service.ts — Redis cache with 5-min TTL + per-merchant invalidation
  • src/middleware/security-headers.middleware.ts — HSTS, nosniff, DENY framing, CSP, etc.
  • src/app.module.ts — Both middlewares registered globally via NestModule.configure()
  • src/db/schema.tscors_origins JSONB column on merchants table
  • GET/PUT /merchants/me/cors endpoints for merchant origin management

This PR's CORS changes (src/api/middleware/security.middleware.ts, src/api/middleware/cors.config.ts) introduce a parallel, static CORS system that:

  1. Conflicts in app.module.ts — PR #22's DynamicCorsMiddleware + SecurityHeadersMiddleware are still registered; your PR doesn't remove them, so both layers run
  2. Conflicts in main.ts — Your app.use(createSecurityMiddleware()) runs alongside PR #22's middleware, creating unpredictable behavior
  3. Downgrades functionality — PR #22 supports per-merchant dynamic origins (stored in DB, cached in Redis); your static CORS_ALLOWED_ORIGINS env var approach replaces this with a flat list

Requested change

Remove the CORS-related files and keep only JWT + rate limiting:

  • Remove src/api/middleware/cors.config.ts and src/api/middleware/cors.config.spec.ts
  • Remove src/api/middleware/security.middleware.ts and src/api/middleware/security.middleware.spec.ts
  • Remove the CORS changes from src/main.ts (the createSecurityMiddleware() / resolveAllowedOrigins() calls)
  • Remove CORS_ALLOWED_ORIGINS from .env.example (already handled by PR #22's per-merchant model)
  • The X-Frame-Options / X-Content-Type-Options / Strict-Transport-Security headers are already covered by PR #22's SecurityHeadersMiddleware

The JWT hardening (src/config/jwt-secret.config.ts, src/auth/jwt.strategy.ts, /auth/refresh) and rate limiting (src/api/middleware/rate-limit.*) are clean, well-tested, and have no conflicts. Those can merge as-is once the CORS overlap is removed.

Happy to help resolve the app.module.ts merge conflict as well once the CORS changes are stripped out.

…eam#22)

Addresses the change request on OrbitStream#24: PR OrbitStream#22 landed dynamic per-merchant CORS
+ security headers on main, so this branch's parallel static CORS layer is
removed. JWT hardening and sliding-window rate limiting are unchanged.

- Remove src/api/middleware/cors.config.ts + security.middleware.ts (+ specs)
- main.ts: drop createSecurityMiddleware()/resolveAllowedOrigins(); keep
  resolveJwtSecrets() and the trust-proxy setting (needed for rate limiting)
- Remove CORS_ALLOWED_ORIGINS from .env.example
- Keep PR OrbitStream#22's DynamicCorsMiddleware + SecurityHeadersMiddleware in
  app.module.ts alongside RateLimitModule
- Scope the rate-limit integration test to rate limiting only
@ogazboiz

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review, @oomokaro1 — you're right, PR #22's dynamic per-merchant CORS landed after this branch was opened. I've rebased onto current main and stripped the parallel CORS layer entirely. This PR is now JWT hardening + rate limiting only.

Changes made:

  • Removed src/api/middleware/cors.config.ts + cors.config.spec.ts
  • Removed src/api/middleware/security.middleware.ts + security.middleware.spec.ts
  • src/main.ts — dropped createSecurityMiddleware() / resolveAllowedOrigins(); kept resolveJwtSecrets() (startup fail-fast) and the trust proxy setting needed by the rate limiter
  • .env.example — removed CORS_ALLOWED_ORIGINS (the security-header vars are covered by PR feat: dynamic CORS + CSP + merchant origin allowlisting #22's SecurityHeadersMiddleware)
  • Resolved the app.module.ts conflict — PR feat: dynamic CORS + CSP + merchant origin allowlisting #22's SecurityHeadersMiddleware + DynamicCorsMiddleware wiring is kept as-is, with RateLimitModule registered alongside it
  • Scoped the rate-limit integration test to rate limiting only

npm run build passes and the JWT + rate-limit suites are green alongside PR #22's CORS suites. The diff is now limited to src/config/jwt-secret.config.ts, src/auth/*, and src/api/middleware/rate-limit.*. Ready for another look when you have a moment.

@oomokaro1 oomokaro1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CORS conflict resolved — all duplicate files removed, PR #22's dynamic middleware preserved. JWT hardening and rate limiting are clean and well-tested. Approved.

@oomokaro1 oomokaro1 merged commit 3258d55 into OrbitStream:main Jun 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[SEC] JWT Secret Hardening + CORS + Rate Limiting

2 participants