JWT Security Hardening for NestJS
Reference Debugger workflow from https://www.jwt.io: paste token -> verify signature with secret/public key -> inspect alg, exp, iss, aud, jti.
Enforces solid-principles-nestjs (DIP via ports, OCP via key rotation) and clean-architecture-nestjs (crypto only in infrastructure/).
1. Algorithm decision
- HS256: single secret, simple microservice. Secret >=256 bits (
openssl rand -base64 32), stored in env/vault, never committed. Good default for @nestjs/jwt monolith.
- RS256: private signs, public verifies. Use for multi-service / third-party verifiers, JWKS endpoint,
kid header for rotation. Private key never in repo; public via JWKS with cache.
- Never accept
alg: none. Pin algorithms: ['HS256'] or ['RS256'] in Strategy + verifyAsync. Reject tokens with unexpected kid.
2. Claims matrix
| Claim |
Required |
Value |
exp |
yes |
access 5-15m, refresh 7d max with rotation |
iat |
yes |
issued-at, check clock skew via clockTolerance: 5 |
iss |
yes |
e.g. my-api, validated on verify |
aud |
yes |
e.g. my-app, validated on verify |
sub |
yes |
userId (UUID, not email) |
jti/sid |
refresh yes |
session binding, denylist on logout |
3. Secrets + config
// infrastructure/config/jwt.config.ts
export default registerAs('jwt', () => ({
accessSecret: getOrThrow('JWT_ACCESS_SECRET'),
refreshSecret: getOrThrow('JWT_REFRESH_SECRET'),
issuer: 'my-api',
audience: 'my-app',
}));
Validate startup: length >=32 chars, different access vs refresh, production Secure; HttpOnly; SameSite cookies for browser flows.
4. Revocation
- Stateless access (short TTL, no DB hit) + stateful refresh (DB/redis by
jti hash).
- Logout: delete/revoke session row + optional access denylist by
jti until exp (redis TTL = remaining exp).
- Reuse detection: if presented refresh
jti already rotated -> revoke session chain + alert.
5. Audit greps (fail on hit unless justified)
algorithm.*none|algorithms.*\* (wildcard alg)
secret:\s*['"]secret|123|test|changeme (weak/hardcoded)
expiresIn:\s*['"][0-9]+d with access >1d (check context; refresh allowed 7d)
ignoreExpiration:\s*true
fromUrlQueryParameter (token in URL)
private.*key|BEGIN PRIVATE in repo (leaked key)
6. jwt.io validation steps
- Copy
issueAccess() output, paste in jwt.io Debugger.
- Enter secret/public key, confirm
Signature Verified.
- Confirm header
alg/typ, payload sub/iss/aud/exp/jti sane.
- Confirm expired token rejected (
exp in past -> TokenExpiredError).
Checklist
1---2name: jwt-security-hardening3description: Use when hardening JWT config, secrets, expiry, revocation, RS256/JWKS in NestJS.4---56# JWT Security Hardening for NestJS78Reference Debugger workflow from https://www.jwt.io: paste token -> verify signature with secret/public key -> inspect `alg, exp, iss, aud, jti`.910Enforces `solid-principles-nestjs` (DIP via ports, OCP via key rotation) and `clean-architecture-nestjs` (crypto only in `infrastructure/`).1112## 1. Algorithm decision1314- **HS256:** single secret, simple microservice. Secret >=256 bits (`openssl rand -base64 32`), stored in env/vault, never committed. Good default for `@nestjs/jwt` monolith.15- **RS256:** private signs, public verifies. Use for multi-service / third-party verifiers, JWKS endpoint, `kid` header for rotation. Private key never in repo; public via JWKS with cache.16- Never accept `alg: none`. Pin `algorithms: ['HS256']` or `['RS256']` in Strategy + `verifyAsync`. Reject tokens with unexpected `kid`.1718## 2. Claims matrix1920| Claim | Required | Value |21|-------|----------|-------|22| `exp` | yes | access 5-15m, refresh 7d max with rotation |23| `iat` | yes | issued-at, check clock skew via `clockTolerance: 5` |24| `iss` | yes | e.g. `my-api`, validated on verify |25| `aud` | yes | e.g. `my-app`, validated on verify |26| `sub` | yes | userId (UUID, not email) |27| `jti/sid` | refresh yes | session binding, denylist on logout |2829## 3. Secrets + config3031```ts32// infrastructure/config/jwt.config.ts33export default registerAs('jwt', () => ({34 accessSecret: getOrThrow('JWT_ACCESS_SECRET'),35 refreshSecret: getOrThrow('JWT_REFRESH_SECRET'),36 issuer: 'my-api',37 audience: 'my-app',38}));39```4041Validate startup: length >=32 chars, different access vs refresh, production `Secure; HttpOnly; SameSite` cookies for browser flows.4243## 4. Revocation4445- Stateless access (short TTL, no DB hit) + stateful refresh (DB/redis by `jti` hash).46- Logout: delete/revoke session row + optional access denylist by `jti` until `exp` (redis TTL = remaining exp).47- Reuse detection: if presented refresh `jti` already rotated -> revoke session chain + alert.4849## 5. Audit greps (fail on hit unless justified)5051- `algorithm.*none|algorithms.*\*` (wildcard alg)52- `secret:\s*['"]secret|123|test|changeme` (weak/hardcoded)53- `expiresIn:\s*['"][0-9]+d` with access >1d (check context; refresh allowed 7d)54- `ignoreExpiration:\s*true`55- `fromUrlQueryParameter` (token in URL)56- `private.*key|BEGIN PRIVATE` in repo (leaked key)5758## 6. jwt.io validation steps59601. Copy `issueAccess()` output, paste in jwt.io Debugger.612. Enter secret/public key, confirm `Signature Verified`.623. Confirm header `alg/typ`, payload `sub/iss/aud/exp/jti` sane.634. Confirm expired token rejected (`exp` in past -> `TokenExpiredError`).6465## Checklist6667- [ ] Secrets via env/vault, rotated, access != refresh.68- [ ] `issuer/audience/algorithms/clockTolerance` set on sign + verify.69- [ ] Refresh hashed, rotated, revokable; logout revokes.70- [ ] No crypto in `domain/application` (DIP); key swap via `useClass` without UseCase edit (OCP).71- [ ] Findings reported as `| Risk | File:line | Fix |` + Clean/SOLID tables.