The CIA Triad: Confidentiality, Integrity and Availability
The CIA triad with examples: secrets leaking through process lists and Git history, forged commit authors, checksums, probes, zone spread and rate limits.
TLDR: The CIA triad names the three things security protects: confidentiality (only the right people can read data), integrity (nobody can change data or code without it being noticed) and availability (the service works when people need it). Classify your data on all three first, because the classification decides which controls you need. In this part you see each property fail on a real system and fix it: a secret visible to every user in the process list, a secret that stays in Git history after you delete it, a commit with a forged author, a tampered download, a deployment that loses traffic, a liveness probe that would restart healthy pods, and a login endpoint without a rate limit.
Every security control answers one question: what are you protecting, and from what? The CIA triad gives you the vocabulary for the first half. A firewall rule protects confidentiality, a signed commit protects integrity, and a second replica protects availability. When you can name the property a control protects, you can also tell when a control is missing, or when two controls pull in different directions.
This part uses the ticket shop from the Threat Modeling guide: customers buy tickets through an API, a payment provider sends signed webhooks, a worker issues the tickets, and Postgres stores users, orders and tickets. The rest of this guide builds on the same system.
Prerequisites
- A Debian or Ubuntu machine where you can use
sudo, ideally a throwaway VM, with the companion repository cloned to~/security-principles-demoas shown in the overview. The outputs come from Ubuntu 26.04 LTS (nginx 1.28, Git 2.55, Python 3.14). - For the Git secret scan: gitleaks 8.30 or newer on your
PATH. - For the availability section: Docker, kind 0.33 and kubectl 1.37.
- The companion repository has every file from this part. Its GitHub Actions workflow runs every command shown here, and the outputs in this part come from those runs.
What the CIA Triad Means
US federal law (44 U.S.C. 3552), which NIST's FIPS 199 uses, defines the three properties like this:
| Property | Definition (short form) | The question you ask | What a failure looks like |
|---|---|---|---|
| Confidentiality | Authorized restrictions on access and disclosure, including protection of personal privacy | Who can read this, and should they? | Leaked customer emails, an API key in a public repository |
| Integrity | Guarding against improper modification or destruction, including authenticity and non-repudiation | Can someone change this without us noticing? | A changed price in an order, a backdoored release, a forged commit |
| Availability | Timely and reliable access to and use of information | Does it work when people need it? | Checkout down during a ticket sale, a deleted backup |
Two details in those definitions matter in practice:
- Integrity includes authenticity. Knowing that data did not change is not enough; you also need to know who produced it. A checksum gives you the first, a signature gives you both.
- Availability is a security property. An attacker who stops your ticket sale for an hour has done real damage without reading or changing a single row. Ransomware attacks availability first.
The triad maps directly onto the STRIDE threat categories: information disclosure breaks confidentiality, tampering and spoofing break integrity, and denial of service breaks availability. Repudiation (denying an action) is why integrity includes non-repudiation, which in practice means audit logs that the actor cannot edit.
Classify the Data First
You cannot protect everything equally, and you should not try. Rate each kind of data on all three properties, then spend your effort where the rating is high. For the ticket shop:
| Data | Confidentiality | Integrity | Availability | Why |
|---|---|---|---|---|
| Payment provider API key | High | High | Medium | Anyone with it can issue refunds; without it nobody can pay |
| Password hashes, customer emails | High | High | Medium | Personal data with legal duties; a changed email hijacks an account |
| Orders and tickets (QR secrets) | Medium | High | High | A forged ticket gets someone in for free; lost orders mean angry customers |
| Event listings and prices | Low (public) | High | High | Public anyway, but a changed price costs money, and the shop must be up |
| Application logs | Medium | Medium | Low | Can contain personal data and tokens; nobody pays for them during an outage |
A few patterns show up in almost every system:
- Secrets and credentials rate high for confidentiality and integrity: they grant access, so a leaked or swapped secret can be as bad as a stolen database.
- Public data still needs integrity. Nobody minds that the price list is readable; everybody minds if someone can edit it.
- Logs are a common leak. Teams treat them as low-value, so they often get weak access control, and they end up holding emails, tokens and session IDs.
The rating also tells you which way to fail. If the database is down, the ticket shop should stop selling (fail closed) rather than sell tickets it cannot record. A public status page should do the opposite and keep showing the last known state.
Confidentiality
Confidentiality fails through over-broad access (a bucket or a dashboard that is public by mistake), weak or reused credentials, unencrypted traffic, and secrets that end up somewhere you did not intend: command lines, logs, Git history, container images and CI output. The first three are covered elsewhere on this site; this section shows the last group, because it catches careful teams too.
Encryption protects data that someone can read but should not understand: a stolen disk, an intercepted connection, a backup in the wrong bucket. It moves the problem to the key, so the rule is to encrypt by default and keep the keys in a key management service. The Cryptography Essentials guide shows how to choose algorithms and manage keys, and the TLS part covers data in transit.
Access control decides who can read data in the first place. On Linux that starts with file permissions; Part 3 covers users, permissions, database roles, Kubernetes RBAC and cloud IAM.
Secrets in Command Lines Are Public
The payment provider's API key is the most valuable secret in the ticket shop. A common way to call an API from a script is to put the key in a header on the command line. On Linux, every local user can read every process's command line through /proc, so the key is visible to anyone on the machine while the command runs.
The Linux examples in this guide need a few packages, installed first. To see the leak, start a local listener that never answers (so the request stays open), then call it the usual way. The nobody user plays another local account:
The key appears in full in the process list, readable by nobody. Process environments are better protected: only the same user and root can read /proc/<pid>/environ, which is why cat fails with "Permission denied". Command lines also end up in ps output pasted into tickets and in audit logs that record execve arguments. Shell history stores what you typed before expansion, so here it holds the export line with the literal key, not the expanded header.
Pass the secret on standard input or in a file instead. curl reads extra headers from a file with -H @file, and @- means standard input:
wait $! waits for the earlier curl, which gives up after 5 seconds with exit code 28 (timeout) because the listener never replies. Now the process list shows -H @- and nothing else. The same rule applies to every tool: prefer --password-stdin (Docker, Helm), --from-file or --env-file options, and files with mode 0600 over arguments.
Secrets in Git Stay in Git
The second classic leak is a .env file committed by mistake. Deleting it in a later commit does not remove it: every earlier commit still contains it, and so does every clone and fork.
git show prints the key from the earlier commit, and gitleaks finds it in history even though the working tree is clean. gitleaks exits with code 1 when it finds a leak, which makes it easy to use as a pre-commit hook or a CI gate.
Once a secret has been pushed, treat it as compromised: rotate it first, at the provider, and only then clean the history if you need to (with git filter-repo). Rewriting history does not reach clones, forks, CI caches or anyone who already pulled. To stop the next leak earlier, keep .env in .gitignore, run a scanner before each commit, and turn on push protection if your Git host has it.
Where Secrets Should Live
A secret should exist in one place that has access control and an audit log, and reach the application at run time:
- A secrets manager: AWS Secrets Manager, Google Secret Manager, Azure Key Vault, HashiCorp Vault or its open-source fork OpenBao. The application or its platform fetches the secret with its own identity, so nobody copies it around.
- Encrypted files in Git with SOPS when you want secrets reviewed and versioned with the code.
- Kubernetes Secrets are only base64-encoded, not encrypted. They are fine as the last step (the External Secrets Operator or the Secrets Store CSI driver can fill them from a secrets manager), provided that you encrypt etcd at rest and limit who can read them. Part 3 shows how far
get secretsreaches.
The Secrets Management guide post compares the managers, and You Cannot Rotate a Secret You Cannot Find covers finding the ones you already have.
Integrity
Integrity fails when someone changes data, code or configuration and nobody notices: a changed price in an order request, a backdoored release binary, a forged commit, a container image replaced under the same tag, or a webhook that pretends to come from the payment provider. Integrity controls work in two ways: they prevent changes (access control, append-only storage, database constraints) or they detect them (hashes, signatures, audit logs).
Detect Changes with Hashes
A cryptographic hash such as SHA-256 is a fingerprint of a file. Store it, recompute it later, and any change shows:
A checksum only proves that the file matches the checksum. If an attacker can replace the file on your download server, they can usually replace SHA256SUMS next to it. To prove who published a file you need a signature, made with a private key the attacker does not have. The hashing part of the Cryptography Essentials guide verifies real releases with GPG and Sigstore.
Anyone Can Write Any Author Name
Git records whatever name and email the committer configured. Nothing checks them:
The %G? field prints N for "no signature" on every commit, including the one that claims to come from the release bot. A signature changes the question from "what name was typed" to "which key signed this": with signing configured, %G? shows G for a good signature from a key in your allowed signers file, and Git hosts mark a commit as verified when the signing key belongs to the committer's account. It still does not prove that the person named as author wrote the change, so check the signer, not the author field. Signing with an SSH key takes four git config lines, shown with real output in the PKI part of the Cryptography Essentials guide. Then make it count: require signed commits on protected branches, and require review, so one stolen laptop key cannot push straight to main.
Pin What You Deploy
A container tag such as python:3.14-alpine is a pointer that the publisher (or an attacker with registry access) can move to a different image at any time. A digest is the hash of the image manifest and cannot move:
The manifests in this guide reference images as tag@sha256:digest. The tag stays for humans; the runtime pulls by digest. Update digests with a bot such as Renovate or Dependabot, so pinning does not mean never patching. Signed images go one step further: the PKI part of the cryptography guide verifies one.
Integrity in the Data Itself
The same idea applies inside the application:
- Never trust values the client can change. The ticket shop computes order totals on the server from the price list, not from the request.
- Verify every webhook with the provider's signature (an HMAC over the body with a shared secret) before acting on it. The HMAC section shows how.
- Let the database enforce rules: foreign keys,
CHECKconstraints, unique ticket codes. - Keep an audit log the application can append to but not change. In Part 3, the API's database role can
INSERTinto the audit table and do nothing else with it, so even a compromised API cannot rewrite history.
Availability
Availability fails through single points of failure, overload (from attackers or from your own popularity), bad deploys, and data loss. The controls are redundancy, health checks that make the platform route around failures, limits that keep one client from starving the others, and backups that you have actually restored.
Remove Single Points of Failure
Run more than one copy of everything that serves users, and make sure the copies cannot all fail together. In Kubernetes that means three things: several replicas, a spread across failure domains (nodes and zones), and a PodDisruptionBudget so that maintenance never takes too many down at once.
Create a local cluster with three worker nodes, labelled as three zones:
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: shop
nodes:
- role: control-plane
- role: worker
labels:
topology.kubernetes.io/zone: zone-a
- role: worker
labels:
topology.kubernetes.io/zone: zone-b
- role: worker
labels:
topology.kubernetes.io/zone: zone-c
The postgres.yaml and worker.yaml files in the companion repository run a lab Postgres (no persistent storage) and a stand-in for the worker. The API is a small Python stand-in for the real application. Its two health endpoints are the important part:
"""Stand-in for the ticket shop API: just enough HTTP to show probes and policies."""
import os
import socket
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
DB = (os.environ.get('DB_HOST', 'postgres'), int(os.environ.get('DB_PORT', '5432')))
def database_reachable():
try:
with socket.create_connection(DB, timeout=1):
return True
except OSError:
return False
class Handler(BaseHTTPRequestHandler):
def reply(self, code, text):
body = (text + '\n').encode()
self.send_response(code)
self.send_header('Content-Type', 'text/plain')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == '/healthz': # liveness: can this process still answer at all?
self.reply(200, 'ok')
elif self.path == '/readyz': # readiness: can it serve real requests right now?
if database_reachable():
self.reply(200, 'ready')
else:
self.reply(503, 'database unreachable')
else:
self.reply(200, f'api {socket.gethostname()}: {self.command} {self.path}')
do_POST = do_GET
def log_message(self, *args):
pass
ThreadingHTTPServer(('', 8080), Handler).serve_forever()
A first attempt at the Deployment, with a mistake that is common in copied examples: the pod template has no labels.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: shop
spec:
replicas: 3
selector:
matchLabels:
app: api
template:
spec:
containers:
- name: api
image: python:3.14.7-alpine3.24@sha256:9e9fde4d32eedce0b661d9ab91e826b62dddf28e928c230ec55f1866cac66b01
command: ['python3', '/app/server.py']
The selector is how a Deployment finds its pods, so pods without the label would never be counted, and the API server refuses the object. The working version adds the labels, the probes, a spread across zones, a dedicated service account and the security settings that Part 3 explains:
apiVersion: v1
kind: ServiceAccount
metadata:
name: api
namespace: shop
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: shop
spec:
replicas: 3
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
serviceAccountName: api
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: api
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: api
image: python:3.14.7-alpine3.24@sha256:9e9fde4d32eedce0b661d9ab91e826b62dddf28e928c230ec55f1866cac66b01
command: ['python3', '/app/server.py']
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /readyz
port: 8080
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ['ALL']
volumeMounts:
- name: code
mountPath: /app
readOnly: true
volumes:
- name: code
configMap:
name: api-code
---
apiVersion: v1
kind: Service
metadata:
name: api
namespace: shop
spec:
selector:
app: api
ports:
- port: 80
targetPort: 8080
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api
namespace: shop
spec:
minAvailable: 2
selector:
matchLabels:
app: api
Each pod runs on a different node in a different zone, so losing a whole zone leaves two replicas. The PodDisruptionBudget allows one voluntary disruption at a time: kubectl drain during node maintenance evicts one API pod, and waits with the next eviction until a replacement is ready. A budget does not protect against involuntary failures such as a node crash; that is what the spread is for.
Three replicas is a common minimum because it survives one failure while another pod is down for a rolling update. In a managed cloud cluster, the zone label is set for you and the same topologySpreadConstraints spread pods across real availability zones.
Liveness and Readiness Probes Do Different Jobs
Health checks let Kubernetes route around failures automatically, but the two main probes answer different questions, and mixing them up causes outages:
- Readiness: should this pod receive traffic right now? A failing readiness probe removes the pod from the Service's endpoints. It is restarted only if something else fails.
- Liveness: is this process broken in a way that only a restart fixes, such as a deadlock? A failing liveness probe kills and restarts the container.
- Startup (not used here): for slow-starting applications, it holds off the other two probes until the first success.
The API's /readyz checks the database; /healthz checks only that the process can answer. Watch what happens when the database disappears:
All three pods went to 0/1 and the Service stopped sending them traffic, but the restart count stayed at 0. When the database came back, they became ready again without a restart. If /healthz had also checked the database, Kubernetes would have restarted every API pod during the outage, which fixes nothing and adds a restart storm to an incident. The Kubernetes documentation on probes draws the same line: the liveness probe passes when the app itself is healthy, and the readiness probe additionally checks that each required back-end service is available. It also warns that wrong liveness probes can cause cascading failures.
Limit What One Client Can Use
A login endpoint without a limit lets one client try thousands of passwords per minute (an integrity and confidentiality problem) and lets a flood of requests starve everyone else (an availability problem). nginx limits the request rate per client address with limit_req:
# 5 login attempts per minute per client address; up to 3 extra are allowed in a burst.
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_status 429;
# Stand-in for the application behind nginx
server {
listen 127.0.0.1:8082;
return 200 "login handled\n";
}
server {
listen 127.0.0.1:8081;
location = /login {
limit_req zone=login burst=3 nodelay;
proxy_pass http://127.0.0.1:8082;
}
}
The first request plus the burst of three got through; the next four got 429 Too Many Requests immediately. One trap when you test this: nginx runs return before limit_req, so a location that only answers with return 200 is never limited. The limit applies to locations that pass requests on (proxy_pass) or serve files, which is why the stand-in application above sits in its own server block. Put the same kind of limit on every expensive or sensitive endpoint (sign-in, password reset, checkout), and add a per-account limit in the application, because attackers spread requests across many addresses. The rate limiting post compares the algorithms.
A rate limit on your own server does not stop a large distributed denial-of-service (DDoS) attack: the traffic fills your network link before nginx sees it. For that you need capacity in front of you, from a CDN or the DDoS protection of your cloud provider (Cloudflare, AWS Shield, Google Cloud Armor and similar), which absorb the flood at the edge.
Backups That Survive the Incident
Backups protect availability (and integrity) against data loss, but only if they survive the event that destroys the data. Ransomware operators look for backups first, and a backup job that runs aws s3 sync --delete copies a deletion or an encryption straight into the backup.
The classic 3-2-1 rule says: 3 copies of the data, on 2 kinds of storage, with 1 copy off site. Veeam extended it to 3-2-1-1-0: one more copy that is offline, air-gapped or immutable, and zero errors in regular restore tests. CISA's #StopRansomware Guide asks for the same things in plain words: offline, encrypted backups of critical data, tested regularly.
On AWS, S3 Object Lock gives you the immutable copy. It works only on versioned buckets, and once it is on you cannot turn it off. In compliance mode nobody, including the account's root user, can delete or overwrite a locked object version before its retention date; AWS documents deleting the whole account as the only way out. That is the point for backups, and also a reason to test with short retention first:
# Creating the bucket with Object Lock also turns on versioning
aws s3api create-bucket --bucket shop-db-backups-example --region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1 \
--object-lock-enabled-for-bucket
# Every new object version is locked for 30 days
aws s3api put-object-lock-configuration --bucket shop-db-backups-example \
--object-lock-configuration '{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Days":30}}}'
Write backups from a role that can put objects but not delete them, keep a copy in a separate account, and restore one on a schedule into a scratch database and run a query against it. A backup you have never restored is a hope, not a control.
Balancing the Three
The properties pull against each other, and most design decisions are trade-offs:
| Decision | Helps | Costs |
|---|---|---|
| Require MFA for every admin action | Confidentiality, integrity | Availability for admins when the MFA provider is down |
| Fail closed when the database is down | Integrity (no unrecorded sales) | Availability (no sales at all) |
| Encrypt everything with a KMS key | Confidentiality | Availability depends on the KMS and on the key policy |
| Keep 90 days of detailed logs | Integrity (investigations), detection | Confidentiality (logs hold personal data), cost |
| Cache pages at a CDN | Availability | Confidentiality, if a private page is cached by mistake |
For each system, decide in advance and write it down: what data it handles, the impact of losing each property, who needs access, and how long it may be down. Those answers become your controls, and they are also what an auditor will ask for.
Common Mistakes
- Treating availability as "the operations team's problem". Deleted backups and login floods are security incidents.
- Secrets in arguments, logs and history. Use stdin, files and secret stores, and scan repositories and CI logs.
- Deleting a leaked secret instead of rotating it. Once pushed, it is public; rotate first.
- Trusting checksums from the same place as the download. Verify signatures.
- Liveness probes that check dependencies. They restart healthy pods during someone else's outage.
- Backups the attacker can delete. Use immutable storage, a separate account, and restore tests.
Summary
- Confidentiality, integrity and availability are the three properties every control protects; integrity includes authenticity.
- Classify data on all three before choosing controls, and decide in advance whether each component fails open or closed.
- Keep secrets off command lines and out of Git, and treat any pushed secret as compromised.
- Hashes detect changes, signatures prove who made them; signed commits show which key committed, and digests stop moved tags.
- Spread replicas across zones, protect them with a PodDisruptionBudget, and keep liveness probes local to the process.
- Rate-limit sensitive endpoints, and keep immutable backups you have restored.
Keep Learning
- Next: Part 2, Defense in Depth, where one attack meets several independent layers
- Cryptography Essentials, for the encryption, hashing and signing behind confidentiality and integrity
- STRIDE in the Threat Modeling guide, which finds threats against each property
- Secrets Management Guide: Vault, AWS Secrets Manager, and Azure Key Vault
- Designing Rate Limiting for APIs
- High Availability Checklist
- Interview questions: secrets management
Tags
Found an issue?