Building a web application means handling things that must remain private. We work with user passwords, credit card numbers, and special keys that let our app talk to other services. If these items get out, it’s a serious problem. I’ve learned that keeping them safe isn’t just one step; it’s a series of good habits built into how we write and run our software.
Think of your code like a house. The logic—how pages load or calculations run—is the furniture and decor. The sensitive data are the keys to the doors and the codes to the safe. You would never nail your front door key to the front porch for everyone to see. Yet, I often see code where the database password is written right there in the open, which is just as risky.
The first and most important habit is to never write secrets directly into your code. Your code will be shared, likely using a system like Git. The moment you save a password in a file and upload it, that secret is potentially exposed to everyone with access to that code history, and removing it later is incredibly difficult.
Instead, we use environment variables. These are like little notes the operating system holds for your application. Your code asks the system, “What’s the database password?” and the system provides it only when the application is running. This keeps the secret out of your code files entirely.
Here’s a basic way I set this up in a Node.js application. I use a file called .env to hold these variables during development. This file is listed in .gitignore so it never gets uploaded.
// .env file (NEVER commit this)
DB_HOST=localhost
DB_USER=app_user
DB_PASSWORD=SuperSecretPassword123!
JWT_SECRET=AnotherRandomLongStringHere
STRIPE_KEY=sk_live_...
Then, in my application, I use a module to load these variables.
// config.js - How I organize settings
require('dotenv').config(); // Loads the .env file
const config = {
environment: process.env.NODE_ENV || 'development',
database: {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
name: process.env.DB_NAME
},
api: {
stripeSecretKey: process.env.STRIPE_KEY,
emailApiKey: process.env.EMAIL_KEY
}
};
// A simple check when the app starts
const requiredSecrets = ['DB_PASSWORD', 'JWT_SECRET', 'STRIPE_KEY'];
requiredSecrets.forEach(key => {
if (!process.env[key]) {
console.error(`ERROR: Missing crucial secret: ${key}`);
process.exit(1); // Stop the app if something is missing
}
});
module.exports = config;
Now, when I need to charge a card, I don’t paste the key. I get it from the configuration.
// A payment route in my app
const config = require('./config');
const stripe = require('stripe')(config.api.stripeSecretKey);
app.post('/api/charge', async (req, res) => {
try {
const charge = await stripe.charges.create({
amount: 2000, // $20.00
currency: 'usd',
source: req.body.token, // Token from frontend
description: 'Test Charge'
});
res.json({ success: true, chargeId: charge.id });
} catch (err) {
res.status(500).json({ error: 'Payment failed' });
}
});
This brings me to a major headache: the frontend. Anything you send to a user’s browser can be seen by them. Every image, style, and JavaScript file is downloaded and can be inspected. This means you cannot, under any circumstances, store a real secret key in your frontend code.
I made this mistake early on. I needed a map on a website, so I put the map service API key right in the JavaScript. It worked, but it meant anyone could find that key, use it for their own projects, and run up my bill. The key was exposed.
The solution is to never let the frontend talk directly to a third-party service with your master key. Instead, let your backend act as a trusted messenger. The frontend asks your server, “Can you get map data for this address?” Your server, which securely holds the key, makes the request, and sends back only the safe, public data.
Here’s how I build that protective gate.
// Backend route - The secure messenger
const axios = require('axios');
app.get('/api/maps/search', async (req, res) => {
const { query } = req.query; // e.g., "coffee shops in seattle"
// My secret key is safe here on the server
const apiKey = process.env.MAPS_API_KEY;
try {
const response = await axios.get(`https://maps.googleapis.com/maps/api/place/textsearch/json`, {
params: { query, key: apiKey }
});
// I can filter or modify the data here if needed
res.json(response.data.results);
} catch (error) {
res.status(502).json({ error: 'Map service error' });
}
});
// Frontend code - Only talks to my backend
async function searchPlaces(query) {
const response = await fetch(`/api/maps/search?query=${encodeURIComponent(query)}`);
const results = await response.json();
displayResults(results); // Your function to show data
}
For actions where the user needs temporary permission, like uploading a file, I use short-lived tokens. After a user logs in, my server can give their browser a special token that works for only 15 minutes to perform a specific task.
// Server creates a limited-use token
const jwt = require('jsonwebtoken');
app.post('/api/get-upload-token', (req, res) => {
// Assume user is authenticated
const userToken = jwt.sign(
{ userId: req.user.id, allowedAction: 'upload' },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // This token self-destructs in 15 minutes
);
res.json({ token: userToken });
});
Even with secrets stored safely, the data itself needs protection. When you save a user’s personal information to a database, it’s “at rest.” If someone gets a copy of that database, all the data is there in the open. To prevent this, we encrypt sensitive fields.
Encryption is a two-way process. You scramble data with a key to make it unreadable ciphertext. Later, with the same key, you unscramble it back to plaintext. The encryption key itself becomes a major secret you must guard.
I don’t try to invent my own encryption. I use the strong, tested tools built into my programming language.
// A service to handle field encryption
const crypto = require('crypto');
class FieldEncryptor {
constructor() {
// The key is from an environment variable, 32 bytes for AES-256
this.key = Buffer.from(process.env.FIELD_ENCRYPTION_KEY, 'hex');
this.algorithm = 'aes-256-cbc';
}
encrypt(text) {
const iv = crypto.randomBytes(16); // Unique "starting vector"
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
// We must store the IV with the data to decrypt later
return {
content: encrypted,
iv: iv.toString('hex')
};
}
decrypt(encryptedData) {
const decipher = crypto.createDecipheriv(
this.algorithm,
this.key,
Buffer.from(encryptedData.iv, 'hex')
);
let decrypted = decipher.update(encryptedData.content, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}
// Using it in a database model (e.g., with Mongoose)
const encryptor = new FieldEncryptor();
const userSchema = new mongoose.Schema({
email: String,
ssn: {
type: String,
// Automatically encrypt when saving
set: function(rawSsn) {
const encrypted = encryptor.encrypt(rawSsn);
// Store as a string containing both parts
return JSON.stringify(encrypted);
},
// Automatically decrypt when reading
get: function(encryptedString) {
if (!encryptedString) return null;
const data = JSON.parse(encryptedString);
return encryptor.decrypt(data);
}
}
});
Now, in the database, the Social Security Number field doesn’t contain 123-45-6789. It contains something like {"content":"a1b2c3d4...","iv":"e5f6g7h8..."}. Without the key, that text is useless.
Protecting data “in transit” is non-negotiable. This means using HTTPS everywhere. It’s the lock on the pipe between the browser and your server. Today, services like Let’s Encrypt make getting an SSL/TLS certificate free and automatic. There is no excuse for not using it.
I also add headers to my server responses to instruct browsers to be more strict.
// Security headers middleware in Express.js
app.use((req, res, next) => {
// Force HTTPS for a year, including subdomains
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// Prevent browsers from guessing content type
res.setHeader('X-Content-Type-Options', 'nosniff');
// Stop this page from being embedded in a frame/iframe (helps stop clickjacking)
res.setHeader('X-Frame-Options', 'DENY');
// Define trusted sources for scripts, styles, etc.
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'");
next();
});
Secrets can’t be set and forgotten. A key that never changes is a key that, if leaked, provides access forever. We need to rotate secrets—change them periodically. Doing this manually for dozens of keys and databases is a nightmare, so we automate it.
The concept is simple: create a new key, start using it, and then retire the old one after giving systems time to switch over. Cloud platforms offer services to manage this, but the logic is similar.
// Conceptual example of a rotation script
class SecretRotationManager {
async rotateDatabasePassword(serviceName) {
// 1. Generate a new strong password
const newPassword = this.generateStrongPassword();
// 2. Update the password in the actual database
await databaseAdminClient.updateUserPassword(serviceName, newPassword);
// 3. Update the secret in our secure vault (e.g., AWS Secrets Manager)
await secretsManager.updateSecret(`${serviceName}_DB_PASS`, newPassword);
// 4. Restart the application so it picks up the new password
// This might involve a rolling restart in a cluster.
console.log(`Password for ${serviceName} rotated.`);
// 5. Schedule the old password for deletion in 7 days
setTimeout(() => {
this.decommissionOldPassword(serviceName, oldPassword);
}, 7 * 24 * 60 * 60 * 1000);
}
generateStrongPassword() {
return require('crypto').randomBytes(32).toString('hex');
}
}
Knowing who accessed what and when is critical. If there’s a breach, audit logs are your first clue. I log any access to sensitive endpoints—where secrets are viewed, configuration is changed, or bulk data is exported.
// Auditing middleware
app.use((req, res, next) => {
const sensitivePaths = ['/admin/secrets', '/api/users/export', '/system/config'];
if (sensitivePaths.some(path => req.path.startsWith(path))) {
const auditEntry = {
timestamp: new Date().toISOString(),
ipAddress: req.ip,
userId: req.user ? req.user.id : 'anonymous',
method: req.method,
path: req.path
};
// Send to a secure logging system, NOT the main application database
secureAuditLogger.log(auditEntry);
// Simple check for odd behavior: many requests from one user in short time
if (this.detectRapidFireRequests(req.user.id)) {
securityAlertSystem.send(`Suspicious activity from user ${req.user.id}`);
}
}
next();
});
Finally, security is about the team’s habits. We use pre-commit hooks in Git to scan for accidentally added secrets. We have separate, isolated environments for development, testing, and production, each with their own set of secrets. The production secrets are the most locked down, accessible to the fewest people.
I once spent a weekend dealing with a leaked API key because a developer saved a configuration file with a real key in a new project and uploaded it to a public repository. It wasn’t malice; it was a simple mistake. Tools and processes are there to make those mistakes hard to commit.
Remember, securing a web application is not a feature you add at the end. It’s the way you think from the very first line of code. You separate secrets from logic, you never trust the frontend with power it shouldn’t have, you protect data at all times, and you plan for keys to change. It’s a continuous practice, and getting it right means your users can trust you with what matters to them.