CWE-522: Insufficiently Protected Credentials - JavaScript
Overview
Insufficiently Protected Credentials in JavaScript/Node.js applications occurs when passwords, API keys, tokens, or other authentication secrets are stored in plaintext, weakly encrypted, hardcoded in source code, committed to version control with .env files, or transmitted insecurely. Node.js has the bcrypt and argon2 packages and the built-in crypto module available, but they have to be integrated and configured correctly.
Primary Defence: Use bcrypt or argon2 npm packages for password hashing with appropriate cost factors, and never store passwords in plaintext.
Common Vulnerable Patterns
Storing Passwords in Plaintext
// VULNERABLE - Plaintext password storage
const express = require('express');
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
username: { type: String, required: true, unique: true },
password: { type: String, required: true } // Plaintext!
});
const User = mongoose.model('User', userSchema);
app.post('/register', async (req, res) => {
const { username, password } = req.body;
// Storing password directly without hashing!
const user = new User({
username,
password // Plaintext password!
});
await user.save();
res.json({ status: 'success' });
});
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await User.findOne({ username });
// Direct password comparison!
if (user && user.password === password) {
return res.json({ token: generateToken(user) });
}
res.status(401).json({ error: 'Invalid credentials' });
});
Why this is vulnerable:
- Database access reveals all passwords immediately.
- Password reuse turns one breach into many.
Hardcoded Credentials
// VULNERABLE - Credentials in source code
// config.js
module.exports = {
database: {
host: 'prod-database.company.com',
port: 5432,
user: 'admin',
password: 'SuperSecret123!' // NEVER DO THIS!
},
apiKeys: {
stripe: 'sk_live_abc123def456ghi789', // Hardcoded API key!
sendgrid: 'SG.abc123def456'
},
jwtSecret: 'my-secret-key-12345' // Hardcoded!
};
// Using hardcoded credentials
const { Pool } = require('pg');
const config = require('./config');
const pool = new Pool({
host: config.database.host,
user: config.database.user,
password: config.database.password // Exposed in code!
});
Why this is vulnerable:
- Repo access or bundled artifacts can leak secrets.
- Rotation requires code changes and redeploys.
Weak Password Hashing
// VULNERABLE - Using crypto.createHash (not for passwords!)
const crypto = require('crypto');
app.post('/register', async (req, res) => {
const { username, password } = req.body;
// MD5/SHA-256 without salt is BROKEN for passwords!
const passwordHash = crypto
.createHash('md5') // or 'sha256'
.update(password)
.digest('hex');
const user = new User({
username,
passwordHash // Weak hash!
});
await user.save();
res.status(201).json({ id: user.id });
});
Why this is vulnerable:
- Fast hashes make brute-force practical.
- No salt or work factor enables rainbow tables.
.env File in Git Repository
# VULNERABLE - .env committed to version control
# .env (accidentally committed to git!)
DATABASE_URL=postgresql://admin:password123@localhost/mydb
JWT_SECRET=my-secret-jwt-key
STRIPE_SECRET_KEY=sk_live_abc123def456
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Nothing in .gitignore excludes it, so the file above is committed. Once it is
in git history the credentials stay reachable through every clone and fork,
whatever a later commit deletes:
// app.js - this code is correct; the flaw is committing the .env file above
require('dotenv').config();
const dbUrl = process.env.DATABASE_URL; // Exposed if .env in git
const jwtSecret = process.env.JWT_SECRET;
Why this is vulnerable:
- Secrets persist in git history and forks.
- Accidental public pushes are common and hard to fully undo.
Insecure JWT Implementation
// VULNERABLE - Weak JWT secret and long expiration
const jwt = require('jsonwebtoken');
// Weak, hardcoded secret!
const JWT_SECRET = 'secret';
const EXPIRATION = '365d'; // 1 year is too long!
function createToken(user) {
// Including password hash in token!
return jwt.sign(
{ id: user.id, email: user.email, passwordHash: user.passwordHash },
JWT_SECRET,
{ expiresIn: EXPIRATION }
);
}
Why this is vulnerable:
- Weak secrets allow token forgery.
- Long expiry and sensitive claims increase impact of leaks.
Frontend Credential Exposure
// VULNERABLE - API keys in frontend code
// React/Vue/Angular component
const API_KEY = 'sk_live_abc123def456'; // Exposed in browser!
fetch('https://api.service.com/data', {
headers: {
'Authorization': `Bearer ${API_KEY}` // Visible in DevTools!
}
});
// Or in environment variables bundled by webpack/vite
// .env (client-side)
VITE_API_KEY=sk_live_abc123def456
REACT_APP_API_KEY=sk_live_abc123def456
// These get bundled into client-side code!
const apiKey = import.meta.env.VITE_API_KEY;
Why this is vulnerable: Anything the bundle contains is public. There is no build step that hides a value from the person running the code, so a key inlined at build time is readable in DevTools, in the served asset and in whatever CDN cache picked it up - and it stays readable after the fix ships, for as long as those caches live.
The distinction that matters is between keys meant to be public and keys that are not. A publishable Stripe key or a domain-restricted Maps key is designed for this and is fine; a secret key, a database URL or an admin token is not, and no amount of obfuscation changes that. Where the browser needs a privileged operation, the call belongs behind an endpoint on your own server that holds the credential and authorizes the caller.
Secure Patterns
Bcrypt Password Hashing
// SECURE - Proper bcrypt implementation
const express = require('express');
const bcrypt = require('bcrypt');
const mongoose = require('mongoose');
// The token helpers from the Secure JWT Implementation section above
const { createAccessToken } = require('./auth-tokens');
const SALT_ROUNDS = 12; // Recommended minimum
// A real bcrypt hash at the same cost, used only to spend the same time on an
// unknown username. It has to be a genuine hash: bcrypt.compare() against '' or a
// malformed string returns in under a millisecond and the timing gap reopens.
const DUMMY_HASH = '$2b$12$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG';
const userSchema = new mongoose.Schema({
username: { type: String, required: true, unique: true },
passwordHash: { type: String, required: true },
role: { type: String, required: true, default: 'user' },
disabled: { type: Boolean, required: true, default: false }
});
const User = mongoose.model('User', userSchema);
app.post('/register', async (req, res) => {
try {
const { username, password } = req.body;
// Generate salt and hash password
const salt = await bcrypt.genSalt(SALT_ROUNDS);
const passwordHash = await bcrypt.hash(password, salt);
const user = new User({
username,
passwordHash
});
await user.save();
res.json({ status: 'success', userId: user._id });
} catch (error) {
logger.error('Registration error:', error);
res.status(500).json({ error: 'Registration failed' });
}
});
app.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
const user = await User.findOne({ username });
if (!user) {
// Do the same work before failing. Returning here answers in microseconds
// where a real user costs ~200ms, which tells an attacker the username exists.
await bcrypt.compare(password, DUMMY_HASH);
return res.status(401).json({ error: 'Invalid credentials' });
}
// Compare password with hash
const isValid = await bcrypt.compare(password, user.passwordHash);
if (isValid) {
const token = createAccessToken(user._id, user.role);
return res.json({ token });
}
res.status(401).json({ error: 'Invalid credentials' });
} catch (error) {
logger.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
Why this works:
- Per-password salts and a tunable cost factor make offline cracking expensive.
- Async
bcryptkeeps the event loop responsive while using standard hash formats. - Comparing against a dummy hash when the user is not found removes the response-time difference that otherwise enumerates accounts. Every login now pays the full hashing cost, so rate-limit the endpoint.
HashiCorp Vault Integration
// SECURE - Vault for secrets management
const vault = require('node-vault');
class VaultSecretsManager {
constructor() {
this.client = vault({
apiVersion: 'v1',
endpoint: process.env.VAULT_ADDR || 'http://localhost:8200',
token: process.env.VAULT_TOKEN
});
}
async getSecret(path) {
try {
const result = await this.client.read(path);
return result.data.data;
} catch (error) {
logger.error(`Failed to retrieve secret: ${path}`);
throw new Error('Secret retrieval failed');
}
}
async getDatabaseCredentials() {
const secrets = await this.getSecret('secret/data/database/postgresql');
return {
host: secrets.host,
port: secrets.port,
user: secrets.username,
password: secrets.password,
database: secrets.database
};
}
async getApiKey(service) {
const secrets = await this.getSecret(`secret/data/api-keys/${service}`);
return secrets.key;
}
}
const vaultSecrets = new VaultSecretsManager();
async function initializeDatabase() {
const dbCreds = await vaultSecrets.getDatabaseCredentials();
const { Pool } = require('pg');
const pool = new Pool({
host: dbCreds.host,
port: dbCreds.port,
user: dbCreds.user,
password: dbCreds.password,
database: dbCreds.database
});
return pool;
}
// The JWT module below requires this file as './vault-secrets'
module.exports = { VaultSecretsManager, vaultSecrets, initializeDatabase };
Why this works:
- Secrets are fetched at runtime and encrypted at rest, staying out of code and repos.
- Policies and short-lived tokens enable least privilege and rotation.
AWS Secrets Manager Integration
// SECURE - AWS Secrets Manager
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
class AWSSecretsManager {
constructor(region = 'us-east-1') {
this.client = new SecretsManagerClient({ region });
}
async getSecret(secretName) {
try {
const command = new GetSecretValueCommand({
SecretId: secretName
});
const response = await this.client.send(command);
if (response.SecretString) {
return JSON.parse(response.SecretString);
} else {
// Binary secret
const buffer = Buffer.from(response.SecretBinary, 'base64');
return buffer.toString('ascii');
}
} catch (error) {
logger.error(`Failed to retrieve secret: ${secretName}`, error);
throw error;
}
}
}
// Usage
const secretsManager = new AWSSecretsManager('us-east-1');
async function initializeApp() {
// Get database credentials
const dbSecret = await secretsManager.getSecret('production/database');
const DATABASE_URL = `postgresql://${dbSecret.username}:${dbSecret.password}@${dbSecret.host}/${dbSecret.database}`;
// Get API keys
const apiKeys = await secretsManager.getSecret('production/api-keys');
const STRIPE_KEY = apiKeys.stripe_key;
const SENDGRID_KEY = apiKeys.sendgrid_key;
return { DATABASE_URL, STRIPE_KEY, SENDGRID_KEY };
}
Why this works:
- Secrets are encrypted with KMS and accessed at runtime via IAM roles.
- Rotation and CloudTrail logging reduce exposure and support auditing.
Secure JWT Implementation
// SECURE - Proper JWT with strong secret
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// Generate strong secret (run once, store in secrets manager)
function generateJwtSecret() {
return crypto.randomBytes(64).toString('hex');
}
// The Vault client from the section above, as its own module
const { vaultSecrets } = require('./vault-secrets');
// Load secret from secure storage. getSecret() returns the KV v2 data object, so
// index the field - passing the object straight to jwt.sign() throws
// "secretOrPrivateKey is not valid key material".
let JWT_SECRET;
let JWT_REFRESH_SECRET;
// Call this once at startup, before anything mints or verifies a token. It is
// exported for that reason: without it both secrets are undefined, and every
// jwt.sign() below fails with "secretOrPrivateKey must have a value" - loudly,
// but at the first request rather than at boot.
async function initializeSecrets() {
JWT_SECRET = (await vaultSecrets.getSecret('secret/data/jwt/access-secret')).key;
JWT_REFRESH_SECRET = (await vaultSecrets.getSecret('secret/data/jwt/refresh-secret')).key;
}
function requireSecret(secret) {
if (!secret) {
throw new Error('JWT secrets not loaded - await initializeSecrets() at startup');
}
return secret;
}
const ACCESS_TOKEN_EXPIRY = '1h'; // Short-lived
const REFRESH_TOKEN_EXPIRY = '30d'; // Longer for refresh
function createAccessToken(userId, role) {
// Minimal claims - no sensitive data
return jwt.sign(
{
userId,
role,
type: 'access'
},
requireSecret(JWT_SECRET),
{
expiresIn: ACCESS_TOKEN_EXPIRY,
issuer: 'myapp',
audience: 'myapp-users'
}
);
}
function createRefreshToken(userId) {
// No role claim here on purpose - a role in a 30-day token is a role that cannot
// be revoked. The refresh endpoint re-reads it from the database instead.
return jwt.sign(
{
userId,
type: 'refresh'
},
requireSecret(JWT_REFRESH_SECRET),
{
expiresIn: REFRESH_TOKEN_EXPIRY,
issuer: 'myapp',
audience: 'myapp-users'
}
);
}
function verifyAccessToken(token) {
// Outside the try on purpose: a secret that was never loaded is a startup
// fault, and the catch below would otherwise report it as 'Invalid token' -
// sending whoever is debugging it to look at the caller's token.
const secret = requireSecret(JWT_SECRET);
try {
const payload = jwt.verify(token, secret, {
issuer: 'myapp',
audience: 'myapp-users'
});
if (payload.type !== 'access') {
throw new Error('Invalid token type');
}
return payload;
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new Error('Token expired');
}
throw new Error('Invalid token');
}
}
function verifyRefreshToken(token) {
const payload = jwt.verify(token, requireSecret(JWT_REFRESH_SECRET), {
issuer: 'myapp',
audience: 'myapp-users'
});
if (payload.type !== 'refresh') {
throw new Error('Invalid token type');
}
return payload;
}
// The examples below require this file as './auth-tokens'
module.exports = {
generateJwtSecret,
initializeSecrets,
createAccessToken,
createRefreshToken,
verifyAccessToken,
verifyRefreshToken,
};
Load the secrets once at startup, before anything serves a request. The route files below import the token helpers and never initialize them themselves - that is deliberate, because two callers racing to fetch the same secret is a worse problem than the one it would solve. This is the only place it happens:
const express = require('express');
const { initializeSecrets } = require('./auth-tokens');
async function start() {
// Before listen(), so a request cannot arrive against unloaded secrets. If
// Vault is unreachable the process exits here rather than serving 500s.
await initializeSecrets();
const app = express();
app.use(express.json());
// ... mount routes ...
app.listen(3000);
}
start().catch((error) => {
console.error('startup failed:', error.message);
process.exit(1);
});
The refresh endpoint, as its own file:
const express = require('express');
const User = require('./models/User');
const { createAccessToken, verifyRefreshToken } = require('./auth-tokens');
const app = express();
app.use(express.json());
app.post('/token/refresh', async (req, res) => {
try {
const { refreshToken } = req.body;
// verifyRefreshToken applies the same issuer/audience constraints the token
// was signed with. Omitting them accepts a correctly-signed token minted for
// a different audience, which is the check's whole purpose - a signature
// alone says nothing about intent. Keeping the check in the module is what
// stops this file needing the raw secret.
const payload = verifyRefreshToken(refreshToken);
// Re-read the role rather than copying it out of the refresh token, which does
// not carry one. `createAccessToken(payload.userId, payload.role)` would mint a
// token whose role claim is undefined - the authorization check downstream then
// compares against nothing.
const user = await User.findById(payload.userId);
if (!user || user.disabled) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
const accessToken = createAccessToken(user._id, user.role);
res.json({ accessToken });
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
Why this works:
- Strong secrets, short-lived access tokens, and minimal claims limit blast radius.
- Issuer/audience checks and separate refresh secret tighten validation.
HTTPS Enforcement
// SECURE - Force HTTPS in Express
const express = require('express');
const helmet = require('helmet');
const https = require('https');
const fs = require('fs');
const app = express();
// The canonical host to redirect to. Never build the target from req.headers.host:
// that is attacker-controlled, so a request with `Host: evil.example` gets a 301 to
// the attacker's site - and an intermediary may cache it for every later visitor.
const CANONICAL_HOST = process.env.PUBLIC_HOST; // e.g. 'app.example.com'
// Behind a load balancer or reverse proxy, TLS terminates upstream and the app sees
// plain HTTP. Without this, req.secure is always false and the redirect below loops.
// The number is how many proxies you actually run behind - set it too high and a
// client can spoof X-Forwarded-Proto and X-Forwarded-For for itself.
app.set('trust proxy', 1);
// Use Helmet for security headers
app.use(helmet({
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
}
}));
// Middleware to enforce HTTPS
app.use((req, res, next) => {
if (!req.secure && process.env.NODE_ENV === 'production') {
return res.redirect(301, `https://${CANONICAL_HOST}${req.originalUrl}`);
}
next();
});
// Production HTTPS server
if (process.env.NODE_ENV === 'production') {
const options = {
key: fs.readFileSync('/path/to/private-key.pem'),
cert: fs.readFileSync('/path/to/certificate.pem')
};
https.createServer(options, app).listen(443, () => {
console.log('HTTPS server running on port 443');
});
// Redirect HTTP to HTTPS. Use middleware rather than a `'*'` route: Express 5
// routes through path-to-regexp v8, where a bare `'*'` is no longer a wildcard
// and `app.get('*', ...)` throws `PathError: Missing parameter name`.
express()
.use((req, res) => res.redirect(301, `https://${CANONICAL_HOST}${req.originalUrl}`))
.listen(80);
} else {
app.listen(3000, () => {
console.log('Development server on port 3000');
});
}
Why this works:
- TLS encrypts credentials in transit and prevents eavesdropping.
- HSTS and redirects ensure HTTP is not accepted in production.
- Redirecting to a configured host rather than the request's own
Hostheader keeps the destination out of the client's control.trust proxyis what makesreq.securereflect theX-Forwarded-Protothe proxy sets, so the check tests the real scheme instead of the hop between the proxy and the app.
Argon2 Password Hashing
// SECURE - Argon2 (most secure option)
const argon2 = require('argon2');
// The token helpers from the Secure JWT Implementation section above
const { createAccessToken } = require('./auth-tokens');
const ARGON2_OPTIONS = {
type: argon2.argon2id, // Recommended variant
memoryCost: 65536, // 64 MB
timeCost: 3, // Iterations
parallelism: 1
};
// Generated once with the options above, so an unknown username costs the same as a
// known one. argon2.verify() returns false for a wrong password rather than throwing.
const DUMMY_HASH = '$argon2id$v=19$m=65536,p=1,t=3$3z5RN1/EVacGyW3LvJZm3g'
+ '$MwqyJN1Sy1j8ofEFwOUjKHsJkWGmXAqShF6KhuJFZp8';
app.post('/register', async (req, res) => {
try {
const { username, password } = req.body;
// Hash password with Argon2
const passwordHash = await argon2.hash(password, ARGON2_OPTIONS);
const user = new User({
username,
passwordHash
});
await user.save();
res.json({ status: 'success' });
} catch (error) {
logger.error('Registration error:', error);
res.status(500).json({ error: 'Registration failed' });
}
});
app.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
const user = await User.findOne({ username });
if (!user) {
// Same reasoning as the bcrypt handler above: pay the cost, then fail
await argon2.verify(DUMMY_HASH, password);
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password with Argon2
const isValid = await argon2.verify(user.passwordHash, password);
if (isValid) {
// Pass the role - createAccessToken(userId, role) with one argument mints a
// token whose role claim is undefined
const token = createAccessToken(user._id, user.role);
return res.json({ token });
}
res.status(401).json({ error: 'Invalid credentials' });
} catch (error) {
logger.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
Why this works:
- Argon2id is memory-hard and resists GPU/ASIC cracking.
- PHC-formatted hashes and tunable parameters allow future upgrades.
Environment Variable Best Practices
# SECURE - Proper .env usage
# .env (NEVER commit to git!)
# JWT_SECRET is appended by the command below rather than written here: a
# secret nobody chose is a secret nobody can accidentally reuse or remember.
DATABASE_URL=postgresql://localhost/mydb
Generate the value rather than typing one. generateJwtSecret() above emits 64
random bytes as 128 hex characters, which is what the startup check further down
measures; a shell one-liner does the same thing at deploy time, and in
production the value comes from the secrets manager instead of a file at all:
Commit a .env.example beside it. Its values are placeholders and are meant to
fail the startup check, so a new environment stops with a clear error rather
than running on a guessable secret:
# .env.example (safe to commit as template)
DATABASE_URL=postgresql://user:password@localhost/dbname
JWT_SECRET=replace_me_see_README
// app.js
require('dotenv').config();
// Validate required environment variables
const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET'];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
throw new Error(`Missing required environment variable: ${envVar}`);
}
}
// Validate JWT secret strength. Measure the decoded key, not the string: 32
// characters of hex is 16 bytes, half the 256 bits HS256 assumes. The generator
// above emits 64 random bytes as 128 hex characters.
const secretBytes = Buffer.from(process.env.JWT_SECRET, 'hex');
if (secretBytes.length < 32) {
throw new Error('JWT_SECRET must decode to at least 32 bytes (64 hex characters)');
}
Why this works:
- Secrets stay out of code and repos via environment injection.
.env.exampleand startup validation prevent missing or weak config.
Testing
To verify credentials are properly protected:
- Check password storage: Query the database to confirm passwords are hashed with bcrypt or Argon2 (look for
$2a$,$2b$, or$argon2prefix), not stored in plaintext - Confirm salt usage: Verify the same password for different users produces different hashes
- Review configuration: Check
.envfiles are not committed to version control and all sensitive values use environment variables - Test JWT tokens: Verify tokens include expiration claims and expire within reasonable timeframes
- Search codebase: Look for hardcoded API keys, passwords, or secrets in source files
- Test authentication: Confirm login works correctly with valid credentials and rejects invalid ones
- Scan dependencies: Use
npm auditto check for known vulnerabilities in authentication libraries
// SECURE - Tests to verify credential protection
const request = require('supertest');
const bcrypt = require('bcrypt');
const app = require('../app');
const User = require('../models/User');
// The token helpers from the Secure JWT Implementation section above
const { createAccessToken, initializeSecrets } = require('./auth-tokens');
describe('Credential Security Tests', () => {
// The token helpers read their secrets from the secrets manager at startup.
// Without this the JWT test fails with "JWT secrets not loaded" rather than
// on anything it is testing.
beforeAll(async () => {
await initializeSecrets();
});
test('passwords are hashed in database', async () => {
const password = 'MyPassword123!';
const response = await request(app)
.post('/register')
.send({
username: 'testuser',
password
});
expect(response.status).toBe(200);
// Check database directly
const user = await User.findOne({ username: 'testuser' });
// Password should not be plaintext
expect(user.passwordHash).not.toBe(password);
// Should look like bcrypt hash
expect(user.passwordHash).toMatch(/^\$2[aby]\$\d+\$/);
// Should verify correctly
const isValid = await bcrypt.compare(password, user.passwordHash);
expect(isValid).toBe(true);
});
test('same password produces different hashes', async () => {
const password = 'SamePassword123!';
await request(app).post('/register').send({
username: 'user1',
password
});
await request(app).post('/register').send({
username: 'user2',
password
});
const user1 = await User.findOne({ username: 'user1' });
const user2 = await User.findOne({ username: 'user2' });
// Different salts produce different hashes
expect(user1.passwordHash).not.toBe(user2.passwordHash);
});
test('no hardcoded secrets in code', () => {
const fs = require('fs');
const path = require('path');
// Check main application files
const appFiles = [
'app.js',
'config.js',
'routes/auth.js'
];
for (const file of appFiles) {
const content = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
// Should not contain obvious hardcoded secrets
expect(content).not.toMatch(/password\s*=\s*['"][^'"]+['"]/i);
expect(content).not.toMatch(/secret\s*=\s*['"](?!process\.env)[^'"]+['"]/i);
expect(content).not.toMatch(/api_key\s*=\s*['"][^'"]+['"]/i);
}
});
test('JWT tokens expire', () => {
const jwt = require('jsonwebtoken');
const userId = '123';
const token = createAccessToken(userId);
// Decode without verification
const decoded = jwt.decode(token);
// Must have expiration
expect(decoded.exp).toBeDefined();
// Should expire in the future
const now = Math.floor(Date.now() / 1000);
expect(decoded.exp).toBeGreaterThan(now);
// But not too far (< 2 hours)
const twoHours = now + (2 * 60 * 60);
expect(decoded.exp).toBeLessThan(twoHours);
});
test('.env file not committed to git', () => {
const fs = require('fs');
// Check .gitignore exists
const gitignore = fs.readFileSync('.gitignore', 'utf8');
// Should ignore .env files
expect(gitignore).toMatch(/\.env/);
expect(gitignore).toMatch(/\.env\.local/);
});
});