ship-safe 1.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ # JWT Security Checklist
2
+
3
+ **Secure your JWT implementation before launch.**
4
+
5
+ Based on [JWT Best Practices 2025](https://jwt.app/blog/jwt-best-practices/) and OWASP guidelines.
6
+
7
+ ---
8
+
9
+ ## Critical: Algorithm & Signing
10
+
11
+ ### 1. [ ] Using secure algorithm (not HS256 in production)
12
+
13
+ ```typescript
14
+ // BAD: HS256 with weak secret
15
+ jwt.sign(payload, 'my-secret', { algorithm: 'HS256' });
16
+
17
+ // GOOD: RS256 (asymmetric) for production
18
+ jwt.sign(payload, privateKey, { algorithm: 'RS256' });
19
+
20
+ // GOOD: ES256 (elliptic curve) - smaller keys, same security
21
+ jwt.sign(payload, privateKey, { algorithm: 'ES256' });
22
+ ```
23
+
24
+ **Why:** HS256 secrets can be brute-forced. RS256/ES256 use public/private key pairs.
25
+
26
+ ### 2. [ ] Algorithm specified in verification (not "auto")
27
+
28
+ ```typescript
29
+ // BAD: Accepts any algorithm (algorithm confusion attack)
30
+ jwt.verify(token, key);
31
+
32
+ // GOOD: Explicitly specify allowed algorithms
33
+ jwt.verify(token, key, { algorithms: ['RS256'] });
34
+ ```
35
+
36
+ ### 3. [ ] Strong secret/key used
37
+
38
+ For HS256 (if you must use it):
39
+ - [ ] At least 256 bits (32 characters)
40
+ - [ ] Random, not dictionary words
41
+ - [ ] Stored in environment variable
42
+
43
+ ```bash
44
+ # Generate a strong secret
45
+ openssl rand -base64 32
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Critical: Token Lifetime
51
+
52
+ ### 4. [ ] Access tokens are short-lived
53
+
54
+ ```typescript
55
+ // GOOD: 15 minutes for access tokens
56
+ jwt.sign(payload, key, { expiresIn: '15m' });
57
+
58
+ // BAD: Long-lived access tokens
59
+ jwt.sign(payload, key, { expiresIn: '30d' }); // Too long!
60
+ ```
61
+
62
+ **Recommended lifetimes:**
63
+ - Access tokens: 15-60 minutes
64
+ - Refresh tokens: 7-30 days
65
+ - Remember me: 30-90 days (with re-auth for sensitive actions)
66
+
67
+ ### 5. [ ] Expiration claim (exp) always set
68
+
69
+ ```typescript
70
+ // Always verify expiration
71
+ jwt.verify(token, key, {
72
+ algorithms: ['RS256'],
73
+ clockTolerance: 30, // 30 seconds tolerance for clock skew
74
+ });
75
+ ```
76
+
77
+ ### 6. [ ] Refresh token rotation implemented
78
+
79
+ ```typescript
80
+ // On each refresh:
81
+ // 1. Invalidate old refresh token
82
+ // 2. Issue new access token
83
+ // 3. Issue new refresh token
84
+
85
+ async function refreshTokens(oldRefreshToken: string) {
86
+ // Verify and invalidate old token
87
+ const payload = await verifyAndInvalidateRefreshToken(oldRefreshToken);
88
+
89
+ // Generate new tokens
90
+ const accessToken = generateAccessToken(payload.userId);
91
+ const refreshToken = generateRefreshToken(payload.userId);
92
+
93
+ return { accessToken, refreshToken };
94
+ }
95
+ ```
96
+
97
+ ---
98
+
99
+ ## High: Storage & Transmission
100
+
101
+ ### 7. [ ] Tokens stored securely
102
+
103
+ | Storage | Access Token | Refresh Token |
104
+ |---------|--------------|---------------|
105
+ | **httpOnly cookie** | Best | Best |
106
+ | **Memory (JS variable)** | OK | No |
107
+ | **sessionStorage** | OK (temporary) | No |
108
+ | **localStorage** | Avoid | Never |
109
+
110
+ ```typescript
111
+ // GOOD: httpOnly cookie (not accessible via JavaScript)
112
+ res.cookie('accessToken', token, {
113
+ httpOnly: true,
114
+ secure: true, // HTTPS only
115
+ sameSite: 'strict', // CSRF protection
116
+ maxAge: 15 * 60 * 1000, // 15 minutes
117
+ });
118
+
119
+ // BAD: localStorage (vulnerable to XSS)
120
+ localStorage.setItem('accessToken', token);
121
+ ```
122
+
123
+ ### 8. [ ] Secure flag set (HTTPS only)
124
+
125
+ ```typescript
126
+ res.cookie('token', value, {
127
+ secure: process.env.NODE_ENV === 'production',
128
+ });
129
+ ```
130
+
131
+ ### 9. [ ] SameSite attribute configured
132
+
133
+ ```typescript
134
+ res.cookie('token', value, {
135
+ sameSite: 'strict', // Prevents CSRF
136
+ // Or 'lax' if you need cross-site GET requests
137
+ });
138
+ ```
139
+
140
+ ---
141
+
142
+ ## High: Validation
143
+
144
+ ### 10. [ ] All claims validated
145
+
146
+ ```typescript
147
+ jwt.verify(token, key, {
148
+ algorithms: ['RS256'], // Algorithm
149
+ issuer: 'https://myapp.com', // Who issued it
150
+ audience: 'https://api.myapp.com', // Who it's for
151
+ clockTolerance: 30, // Clock skew tolerance
152
+ });
153
+ ```
154
+
155
+ ### 11. [ ] Issuer (iss) validated
156
+
157
+ ```typescript
158
+ // Prevent tokens from other services being accepted
159
+ if (payload.iss !== 'https://myapp.com') {
160
+ throw new Error('Invalid issuer');
161
+ }
162
+ ```
163
+
164
+ ### 12. [ ] Audience (aud) validated
165
+
166
+ ```typescript
167
+ // Prevent tokens meant for other services
168
+ if (payload.aud !== 'https://api.myapp.com') {
169
+ throw new Error('Invalid audience');
170
+ }
171
+ ```
172
+
173
+ ---
174
+
175
+ ## Medium: Token Revocation
176
+
177
+ ### 13. [ ] Token revocation mechanism exists
178
+
179
+ JWTs can't be invalidated by default. Implement one of:
180
+
181
+ **Option A: Short expiry + refresh tokens**
182
+ - Access tokens are short-lived (15 min)
183
+ - Revoke refresh tokens to force re-authentication
184
+
185
+ **Option B: Token blacklist/denylist**
186
+ ```typescript
187
+ const revokedTokens = new Set();
188
+
189
+ function verifyToken(token) {
190
+ const payload = jwt.verify(token, key);
191
+ if (revokedTokens.has(payload.jti)) {
192
+ throw new Error('Token revoked');
193
+ }
194
+ return payload;
195
+ }
196
+ ```
197
+
198
+ **Option C: Token versioning**
199
+ ```typescript
200
+ // Store token version in database
201
+ // Increment on password change/logout
202
+ if (payload.tokenVersion !== user.tokenVersion) {
203
+ throw new Error('Token invalidated');
204
+ }
205
+ ```
206
+
207
+ ### 14. [ ] Logout invalidates tokens
208
+
209
+ ```typescript
210
+ async function logout(userId: string) {
211
+ // Increment token version to invalidate all tokens
212
+ await db.user.update({
213
+ where: { id: userId },
214
+ data: { tokenVersion: { increment: 1 } },
215
+ });
216
+
217
+ // Clear refresh tokens
218
+ await db.refreshToken.deleteMany({
219
+ where: { userId },
220
+ });
221
+ }
222
+ ```
223
+
224
+ ---
225
+
226
+ ## Medium: Payload Security
227
+
228
+ ### 15. [ ] No sensitive data in payload
229
+
230
+ ```typescript
231
+ // BAD: Sensitive data in JWT (readable by anyone with the token)
232
+ {
233
+ sub: 'user123',
234
+ email: 'user@example.com',
235
+ passwordHash: '...', // NEVER store passwords/hashes!
236
+ ssn: '123-45-6789', // NEVER!
237
+ creditCard: '4111...', // NEVER!
238
+ }
239
+
240
+ // GOOD: Minimal payload
241
+ {
242
+ sub: 'user123',
243
+ role: 'user',
244
+ iat: 1234567890,
245
+ exp: 1234568790,
246
+ }
247
+ ```
248
+
249
+ ### 16. [ ] JTI (JWT ID) included for tracking
250
+
251
+ ```typescript
252
+ import { v4 as uuid } from 'uuid';
253
+
254
+ const token = jwt.sign({
255
+ sub: userId,
256
+ jti: uuid(), // Unique token ID
257
+ }, key);
258
+ ```
259
+
260
+ ---
261
+
262
+ ## Code Examples
263
+
264
+ ### Complete JWT Service
265
+
266
+ ```typescript
267
+ import jwt from 'jsonwebtoken';
268
+ import { randomUUID } from 'crypto';
269
+
270
+ const ACCESS_TOKEN_EXPIRY = '15m';
271
+ const REFRESH_TOKEN_EXPIRY = '7d';
272
+
273
+ interface TokenPayload {
274
+ sub: string;
275
+ role: string;
276
+ jti: string;
277
+ iat: number;
278
+ exp: number;
279
+ }
280
+
281
+ export function generateAccessToken(userId: string, role: string): string {
282
+ return jwt.sign(
283
+ {
284
+ sub: userId,
285
+ role,
286
+ jti: randomUUID(),
287
+ },
288
+ process.env.JWT_PRIVATE_KEY!,
289
+ {
290
+ algorithm: 'RS256',
291
+ expiresIn: ACCESS_TOKEN_EXPIRY,
292
+ issuer: process.env.JWT_ISSUER,
293
+ audience: process.env.JWT_AUDIENCE,
294
+ }
295
+ );
296
+ }
297
+
298
+ export function verifyAccessToken(token: string): TokenPayload {
299
+ return jwt.verify(token, process.env.JWT_PUBLIC_KEY!, {
300
+ algorithms: ['RS256'],
301
+ issuer: process.env.JWT_ISSUER,
302
+ audience: process.env.JWT_AUDIENCE,
303
+ clockTolerance: 30,
304
+ }) as TokenPayload;
305
+ }
306
+ ```
307
+
308
+ ---
309
+
310
+ ## Tools
311
+
312
+ - **jwt.io** - Decode and inspect JWTs
313
+ - **jwt-cli** - Command line JWT tool
314
+ - **ship-safe** - Scan for leaked tokens in code
315
+
316
+ ```bash
317
+ npx ship-safe scan .
318
+ ```
319
+
320
+ ---
321
+
322
+ **Remember: JWTs are not encrypted by default. Anyone with the token can read the payload. Only the signature prevents tampering.**
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Next.js Middleware Rate Limiting
3
+ * =================================
4
+ *
5
+ * Rate limiting at the edge using Next.js middleware.
6
+ * No external dependencies - uses in-memory store (resets on deploy).
7
+ *
8
+ * WHY USE MIDDLEWARE:
9
+ * - Blocks requests before they hit your API
10
+ * - Runs at the edge (faster)
11
+ * - Protects all routes with one file
12
+ *
13
+ * LIMITATIONS:
14
+ * - In-memory store resets on deploy/restart
15
+ * - Doesn't sync across serverless instances
16
+ * - For production, use Upstash Redis instead
17
+ *
18
+ * USAGE:
19
+ * Copy this to middleware.ts in your Next.js project root.
20
+ */
21
+
22
+ import { NextResponse } from 'next/server';
23
+ import type { NextRequest } from 'next/server';
24
+
25
+ // =============================================================================
26
+ // CONFIGURATION
27
+ // =============================================================================
28
+
29
+ const RATE_LIMIT_CONFIG = {
30
+ // Requests per window
31
+ maxRequests: 60,
32
+ // Window size in seconds
33
+ windowSizeSeconds: 60,
34
+ // Paths to rate limit (regex patterns)
35
+ protectedPaths: [
36
+ /^\/api\//, // All API routes
37
+ ],
38
+ // Stricter limits for sensitive paths
39
+ strictPaths: [
40
+ { pattern: /^\/api\/auth\//, maxRequests: 5 },
41
+ { pattern: /^\/api\/ai\//, maxRequests: 10 },
42
+ ],
43
+ // Paths to exclude from rate limiting
44
+ excludedPaths: [
45
+ /^\/api\/health/, // Health checks
46
+ /^\/_next\//, // Next.js internals
47
+ /^\/favicon\.ico/,
48
+ ],
49
+ };
50
+
51
+ // =============================================================================
52
+ // IN-MEMORY RATE LIMIT STORE
53
+ // =============================================================================
54
+
55
+ interface RateLimitEntry {
56
+ count: number;
57
+ resetTime: number;
58
+ }
59
+
60
+ // Simple in-memory store (use Redis for production)
61
+ const rateLimitStore = new Map<string, RateLimitEntry>();
62
+
63
+ // Clean up old entries periodically
64
+ setInterval(() => {
65
+ const now = Date.now();
66
+ for (const [key, entry] of rateLimitStore.entries()) {
67
+ if (entry.resetTime < now) {
68
+ rateLimitStore.delete(key);
69
+ }
70
+ }
71
+ }, 60000); // Clean every minute
72
+
73
+ // =============================================================================
74
+ // RATE LIMIT LOGIC
75
+ // =============================================================================
76
+
77
+ function getClientIP(request: NextRequest): string {
78
+ // Check various headers for the real IP
79
+ const forwarded = request.headers.get('x-forwarded-for');
80
+ if (forwarded) {
81
+ return forwarded.split(',')[0].trim();
82
+ }
83
+
84
+ const realIP = request.headers.get('x-real-ip');
85
+ if (realIP) {
86
+ return realIP;
87
+ }
88
+
89
+ // Fallback
90
+ return 'unknown';
91
+ }
92
+
93
+ function checkRateLimit(
94
+ ip: string,
95
+ maxRequests: number,
96
+ windowSeconds: number
97
+ ): { allowed: boolean; remaining: number; resetTime: number } {
98
+ const now = Date.now();
99
+ const key = `ratelimit:${ip}`;
100
+ const windowMs = windowSeconds * 1000;
101
+
102
+ let entry = rateLimitStore.get(key);
103
+
104
+ // Create new entry if doesn't exist or window expired
105
+ if (!entry || entry.resetTime < now) {
106
+ entry = {
107
+ count: 0,
108
+ resetTime: now + windowMs,
109
+ };
110
+ }
111
+
112
+ entry.count++;
113
+ rateLimitStore.set(key, entry);
114
+
115
+ const remaining = Math.max(0, maxRequests - entry.count);
116
+ const allowed = entry.count <= maxRequests;
117
+
118
+ return { allowed, remaining, resetTime: entry.resetTime };
119
+ }
120
+
121
+ function getMaxRequests(pathname: string): number {
122
+ // Check strict paths first
123
+ for (const strict of RATE_LIMIT_CONFIG.strictPaths) {
124
+ if (strict.pattern.test(pathname)) {
125
+ return strict.maxRequests;
126
+ }
127
+ }
128
+ return RATE_LIMIT_CONFIG.maxRequests;
129
+ }
130
+
131
+ function shouldRateLimit(pathname: string): boolean {
132
+ // Check exclusions first
133
+ for (const pattern of RATE_LIMIT_CONFIG.excludedPaths) {
134
+ if (pattern.test(pathname)) {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ // Check if path is protected
140
+ for (const pattern of RATE_LIMIT_CONFIG.protectedPaths) {
141
+ if (pattern.test(pathname)) {
142
+ return true;
143
+ }
144
+ }
145
+
146
+ return false;
147
+ }
148
+
149
+ // =============================================================================
150
+ // MIDDLEWARE
151
+ // =============================================================================
152
+
153
+ export function middleware(request: NextRequest) {
154
+ const pathname = request.nextUrl.pathname;
155
+
156
+ // Skip if path is not rate limited
157
+ if (!shouldRateLimit(pathname)) {
158
+ return NextResponse.next();
159
+ }
160
+
161
+ const ip = getClientIP(request);
162
+ const maxRequests = getMaxRequests(pathname);
163
+
164
+ const { allowed, remaining, resetTime } = checkRateLimit(
165
+ ip,
166
+ maxRequests,
167
+ RATE_LIMIT_CONFIG.windowSizeSeconds
168
+ );
169
+
170
+ // Add rate limit headers to response
171
+ const response = allowed
172
+ ? NextResponse.next()
173
+ : NextResponse.json(
174
+ {
175
+ error: 'Too Many Requests',
176
+ message: 'Rate limit exceeded. Please try again later.',
177
+ retryAfter: Math.ceil((resetTime - Date.now()) / 1000),
178
+ },
179
+ { status: 429 }
180
+ );
181
+
182
+ response.headers.set('X-RateLimit-Limit', maxRequests.toString());
183
+ response.headers.set('X-RateLimit-Remaining', remaining.toString());
184
+ response.headers.set('X-RateLimit-Reset', resetTime.toString());
185
+
186
+ if (!allowed) {
187
+ response.headers.set(
188
+ 'Retry-After',
189
+ Math.ceil((resetTime - Date.now()) / 1000).toString()
190
+ );
191
+ }
192
+
193
+ return response;
194
+ }
195
+
196
+ // =============================================================================
197
+ // MATCHER CONFIGURATION
198
+ // =============================================================================
199
+
200
+ export const config = {
201
+ matcher: [
202
+ /*
203
+ * Match all request paths except:
204
+ * - _next/static (static files)
205
+ * - _next/image (image optimization files)
206
+ * - favicon.ico (favicon file)
207
+ * - public folder
208
+ */
209
+ '/((?!_next/static|_next/image|favicon.ico|public/).*)',
210
+ ],
211
+ };