Secure Coding Practices Every DevOps Engineer Should Know
The majority of security vulnerabilities come from a handful of coding mistakes: trusting user input, sloppy error handling, and weak authentication. These are not exotic attacks. They are basic errors that keep showing up in breach after breach because developers treat security as something to bolt on later.
If you write infrastructure as code, deployment scripts, automation tools, or internal APIs, you write code that can be attacked. The same secure coding principles that apply to product engineers apply to you. This guide covers the four areas that matter most: input validation, output encoding, error handling, and authentication.
Input Validation: Never Trust Anything
Every piece of data that enters your application must be validated before use. This includes form fields, URL parameters, API request bodies, file uploads, HTTP headers (including cookies and User-Agent), and environment variables. Any of these can be manipulated by an attacker.
Allowlists Beat Denylists Every Time
A denylist tries to block known-bad input. It always fails because attackers find bypasses through encoding tricks, Unicode characters, and new vectors you have not thought of:
# BAD: Denylist approach - attackers will bypass this
def sanitize_input_bad(user_input):
dangerous = ["'", '"', ';', '--', '/*', '*/', '<', '>']
for char in dangerous:
user_input = user_input.replace(char, '')
return user_input
An allowlist only permits known-good input. It is much harder to bypass because you define exactly what is acceptable:
import re
# GOOD: Allowlist approach - define what's allowed
def validate_username(username):
pattern = r'^[a-zA-Z0-9_]{3,20}$'
if re.match(pattern, username):
return username
raise ValueError("Invalid username format")
SQL Injection Prevention
This is the most common and dangerous injection attack. The fix is simple: never concatenate user input into SQL strings.
# VULNERABLE: String concatenation
def get_user_bad(username):
query = f"SELECT * FROM users WHERE username = '{username}'"
return db.execute(query) # Attacker input: ' OR '1'='1
# SECURE: Parameterized queries
def get_user_good(username):
query = "SELECT * FROM users WHERE username = %s"
return db.execute(query, (username,))
Always use parameterized queries or an ORM. There is no good reason to build SQL strings by hand in 2025.
Command Injection Prevention
This one is especially dangerous in DevOps scripts where you are already running system commands:
import subprocess
# VULNERABLE: Shell injection via string formatting
# An attacker could pass "google.com; rm -rf /" as the hostname
def ping_host_bad(hostname):
subprocess.run(f"ping -c 4 {hostname}", shell=True)
# SECURE: Use subprocess with list arguments
def ping_host_good(hostname):
import re
if not re.match(r'^[a-zA-Z0-9.-]+$', hostname):
raise ValueError("Invalid hostname")
result = subprocess.run(
['ping', '-c', '4', hostname],
capture_output=True, text=True, timeout=30
)
return result.stdout
Pass arguments as a list, not a string. Avoid shell=True. If shell interpolation is unavoidable, use shlex.quote().
File Upload Validation
File uploads are particularly dangerous. Never trust the file extension alone:
import magic
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
ALLOWED_MIME_TYPES = {'image/png', 'image/jpeg', 'image/gif'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
def validate_image_upload(file):
# Check file size
file.seek(0, os.SEEK_END)
if file.tell() > MAX_FILE_SIZE:
raise ValueError("File too large")
file.seek(0)
# Check extension
ext = file.filename.rsplit('.', 1)[-1].lower()
if ext not in ALLOWED_EXTENSIONS:
raise ValueError("Extension not allowed")
# Verify actual content type using magic bytes
mime_type = magic.from_buffer(file.read(2048), mime=True)
file.seek(0)
if mime_type not in ALLOWED_MIME_TYPES:
raise ValueError(f"Content type not allowed: {mime_type}")
# Sanitize filename to prevent path traversal
return os.path.basename(file.filename)
Verify content type using magic bytes. Sanitize filenames. Set strict size limits. Store files outside the web root.
Validate at Every Layer
Client-side validation is for user experience only. It can always be bypassed. Server-side validation is required. Database constraints are your last line of defense. Use all three.
from pydantic import BaseModel, validator, Field
class CreateUserRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=20)
email: str = Field(..., max_length=254)
age: Optional[int] = Field(None, ge=13, le=150)
@validator('username')
def username_alphanumeric(cls, v):
if not v.replace('_', '').isalnum():
raise ValueError('Username must be alphanumeric')
return v.lower()
Output Encoding: Context Matters
Input validation controls what comes in. Output encoding ensures data is safely rendered when it goes out. The most common vulnerability prevented by proper output encoding is Cross-Site Scripting (XSS).
The same user input requires different encoding depending on where it appears:
<p>Hello, USER_INPUT</p> <!-- HTML context -->
<a href="/search?q=USER_INPUT">Link</a> <!-- URL context -->
<script>var name = 'USER_INPUT';</script> <!-- JavaScript context -->
Each context has different dangerous characters and requires different encoding.
HTML Context
import html
# SECURE: HTML escaping
def safe_html_output(user_input):
return f"<p>Hello, {html.escape(user_input)}</p>"
JavaScript Context
Never use string interpolation. Use JSON encoding:
import json
@app.route('/page')
def page():
user_data = {'name': request.args.get('name', '')}
return f'''
<script>
const userData = {json.dumps(user_data)};
</script>
'''
URL Context
from urllib.parse import quote, urlencode
def build_search_url(query, page):
params = urlencode({'q': query, 'page': page})
return f"/search?{params}"
Use Framework Auto-Escaping
Modern frameworks handle encoding by default. React escapes JSX output. Jinja2 auto-escapes templates. Go's html/template package is context-aware. Do not disable these protections unless you have a very good reason.
The exception is rich text content where users need some HTML. Use a sanitization library like bleach (Python) or DOMPurify (JavaScript) with a strict allowlist of tags:
import bleach
ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a']
def sanitize_rich_text(html_content):
return bleach.clean(html_content, tags=ALLOWED_TAGS, strip=True)
Add Content Security Policy Headers
CSP is a defense-in-depth mechanism that mitigates XSS even if your encoding fails:
@app.after_request
def add_security_headers(response):
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"frame-ancestors 'none';"
)
return response
Error Handling: Reveal Nothing Useful
Verbose error messages are a gift to attackers. A stack trace can reveal internal file paths, framework versions, database table names, and query structure. A different error message for "user not found" versus "wrong password" tells an attacker which usernames exist.
The Principle: Fail Securely
Log detailed errors internally. Show generic messages externally. Never leave the system in an inconsistent state.
import os
IS_PRODUCTION = os.getenv('FLASK_ENV') == 'production'
@app.errorhandler(Exception)
def handle_exception(error):
app.logger.exception(f"Unhandled exception: {error}")
if IS_PRODUCTION:
return jsonify({
'error': 'An internal error occurred',
'request_id': get_request_id()
}), 500
else:
return jsonify({
'error': str(error),
'traceback': traceback.format_exc()
}), 500
Authentication Errors Should Be Generic
Do not reveal whether a username exists:
def login(username, password):
user = User.query.filter_by(username=username).first()
# WRONG: Different messages leak information
# if not user: return error("User not found")
# if not check_password(...): return error("Incorrect password")
# RIGHT: Generic message for all failures
if not user or not check_password(password, user.password_hash):
time.sleep(0.1 + random.uniform(0, 0.05)) # Prevent timing attacks
return error("Invalid username or password")
return success(generate_token(user))
What to Log and What Not to Log
Log security events with structured data for searching and alerting. Never log passwords, full credit card numbers, API keys, tokens, or PII.
# BAD
logger.info(f"Login attempt: user={username}, password={password}")
# GOOD
logger.info(f"Login attempt: user={username}")
logger.debug(f"API request with token: {api_token[:8]}...")
Handle Transactions Properly
When database operations fail, roll back. Do not leave partial state:
@contextmanager
def transaction():
try:
yield db.session
db.session.commit()
except Exception:
db.session.rollback()
raise
Authentication and Session Security
Authentication is where most high-impact breaches start. Get this wrong and nothing else matters.
Password Hashing
Use bcrypt or Argon2. Never MD5 or SHA256 alone. Never store plain text.
import bcrypt
def hash_password(password: str) -> str:
salt = bcrypt.gensalt(rounds=12)
return bcrypt.hashpw(password.encode(), salt).decode()
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode(), password_hash.encode())
These algorithms are slow by design, making brute-force attacks expensive. They handle salting automatically, which prevents rainbow table attacks. And you can increase the work factor as hardware gets faster.
Secure Session Configuration
# Flask
app.config.update(
SECRET_KEY=os.environ['SECRET_KEY'],
SESSION_COOKIE_SECURE=True, # HTTPS only
SESSION_COOKIE_HTTPONLY=True, # No JavaScript access
SESSION_COOKIE_SAMESITE='Lax', # CSRF protection
PERMANENT_SESSION_LIFETIME=timedelta(hours=24),
)
// Express.js
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true,
httpOnly: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000
}
}));
Regenerate the session ID after login, privilege escalation, and password changes. This prevents session fixation attacks.
JWT Security
JWTs are popular but easy to misconfigure:
import jwt
JWT_SECRET = os.environ['JWT_SECRET'] # At least 256 bits
ACCESS_TOKEN_EXPIRE_MINUTES = 15
def create_access_token(user_id: int) -> str:
payload = {
'sub': str(user_id),
'type': 'access',
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
'jti': secrets.token_hex(16)
}
return jwt.encode(payload, JWT_SECRET, algorithm='HS256')
def verify_token(token: str) -> dict:
try:
return jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
except jwt.ExpiredSignatureError:
raise AuthenticationError("Token expired")
except jwt.InvalidTokenError:
raise AuthenticationError("Invalid token")
The common JWT mistakes: using the none algorithm, accepting the algorithm from the token header (algorithm confusion attack), storing sensitive data in the payload (it is base64, not encrypted), using weak secrets, and forgetting to set expiration. Avoid all of them.
Rate Limiting
Every authentication endpoint needs rate limiting. Without it, brute force attacks are trivial:
from flask_limiter import Limiter
limiter = Limiter(app, key_func=get_remote_address)
@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute")
def login():
# authentication logic
pass
@app.route('/api/password-reset', methods=['POST'])
@limiter.limit("3 per hour")
def request_password_reset():
# reset logic
pass
Password Reset Security
Generate cryptographically secure tokens. Store the hash, not the token itself. Set short expiration times. Invalidate all existing sessions after a password change:
def create_password_reset_token(user):
token = secrets.token_urlsafe(32)
user.reset_token_hash = hashlib.sha256(token.encode()).hexdigest()
user.reset_token_expires = datetime.utcnow() + timedelta(hours=1)
db.session.commit()
return token # Send this via email
The Checklist
These are the non-negotiable practices:
- Validate all input with allowlists, not denylists
- Use parameterized queries for all database access
- Avoid shell commands in application code; when unavoidable, use subprocess with list arguments
- Encode output based on the rendering context (HTML, URL, JavaScript)
- Use framework auto-escaping and do not disable it
- Show generic error messages to users; log details internally
- Hash passwords with bcrypt or Argon2
- Set Secure, HttpOnly, and SameSite flags on all session cookies
- Keep access tokens short-lived (15 minutes or less)
- Rate limit every authentication endpoint
- Add CSP headers as a defense-in-depth measure
- Regenerate session IDs after login and privilege changes
None of these are difficult. None of them require expensive tools. They just require discipline and the decision to treat security as a first-class concern in your code, not something to add before the audit.
Related Security Posts
- Security-Focused Code Reviews - Turn these coding practices into a review checklist that catches injection flaws, auth bypasses, and unsafe deserialization in PRs
- How to Integrate DAST Into Your CI/CD Pipeline - Verify your secure coding holds up at runtime by probing for XSS, SQL injection, and broken authentication in staging
- Dependency Scanning: Finding Vulnerabilities Before Attackers Do - Your own code might be solid, but one vulnerable dependency can undo all of it
For a deeper dive into encryption, hashing, and key management, see our guide on cryptography essentials.
We earn commissions when you shop through the links below.
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
Acronis
The most secure backup
Acronis: the most secure backup solution for your data

QuizAPI
Developer-first quiz platform
Build, generate, and embed quizzes with a powerful REST API. AI-powered question generation and live multiplayer.
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorFound an issue?