Web Security (OWASP Top 10:2025) Module 5 – A04:2025 Cryptographic Failures
Protecting sensitive data: classify, minimise, mask
Sort the data an app handles into protection levels, then keep less of it: truncate, mask and tokenise, with a run that checks no masked value leaks.
What you will learn
- Classify the data an application handles into protection levels with concrete handling rules
- Limit what is kept and shown by truncating, masking, tokenising or encrypting data
- Find where sensitive data leaks, from URLs and logs to caches, browser storage and uploads
- Define retention and deletion for each data class, and map them onto India's DPDP Act duties
Before you start
What you’ll learn · Before you start
What you will learn
- Classify the data an application handles into protection levels with concrete handling rules
- Limit what is kept and shown by truncating, masking, tokenising or encrypting data
- Find where sensitive data leaks, from URLs and logs to caches, browser storage and uploads
- Define retention and deletion for each data class, and map them onto India's DPDP Act duties
Before you start
On this page
A support screen shows every customer’s full card number, date of birth and home address to 120 agents, and the same API response also feeds the mobile app, an analytics script and the request log. Nothing has been stolen yet, but each customer’s data now sits in four more places than any task needed, and each copy is one more way to lose it. The A04:2025 prevention list starts here: know which data is sensitive, and do not keep what you do not need.
You will sort an application’s data into four protection levels with a rule for each, keep less of it by truncating, masking and tokenising, and check in a recorded run that a masked value never gives the original away.
Classify first: four levels, each with its own rules
Before choosing any control, decide how much each field matters. ASVS 5.0.0 requirement 14.1.1 asks that sensitive data be identified and sorted into protection levels, and 14.1.2 that every level have written rules covering encryption, integrity, retention, logging and access to those logs. Four levels are enough to start, and the rules matter more than the names:
| Level | Examples | Logged | Sent to third parties | Shown to staff |
|---|---|---|---|---|
| Public | Product names, prices | Yes | Yes | Yes |
| Internal | Order numbers, stock counts | Yes | Only to processors under contract | Yes |
| Confidential | Email, phone, address, date of birth | Masked only | Only what the task needs | Masked, in full on request |
| Restricted | Card numbers, passwords, session tokens, API keys | Never | Never | Never in full |
Read the restricted row first: a card number appears in full nowhere a person looks. ASVS 14.1.1 adds a trap that catches many teams: data that is merely encoded is still data in the clear, so a Base64 string or the readable payload of a JWT carries the level of whatever it holds. And a field that nobody has classified is the riskiest of all, because no rule applies to it. Treat it as confidential until someone decides.
Key idea
Give every field one of four levels, and let the level decide where the field may go: into logs, to third parties, onto screens.
Checkpoint
Check: Protection levels and their rules
Minimise first, then mask what you must show
The cheapest protection is data you do not have. The A04:2025 list says it plainly: data that is not kept cannot be stolen. data minimisation: Collecting, keeping and showing only the personal data a task needs, for only as long as it needs it. Data that is not kept cannot be stolen or leaked. has three moves, collect less, keep less and show less, and four techniques do most of the work:
- Truncate: keep only part of a value, such as the last four digits of a card, when the rest is never needed again.
- Mask: keep the value but show it obscured, such as
c********@mail.example, with an explicit action to reveal it. - Tokenise: replace the value with a random tokenisation: Replacing a sensitive value, such as a card number, with a random token and keeping the real value in a separate vault with its own access control. The token reveals nothing on its own. and keep the real one in a separate vault, often a payment provider’s.
- Keyed hash: store an HMAC of the value with a key held outside the database, when you only need to match equal values: a keyed hash: A hash computed with a secret key, such as HMAC-SHA-256. Equal inputs give equal outputs while the key stays the same, but without the key nobody can rebuild the values by hashing guesses. cannot be rebuilt without the key.
ASVS 14.2.6 asks for the masked form by default: return only the part of a value that a feature needs, and if the whole value is needed, obscure it until the user asks. The program below applies the techniques to 1,000 made-up customers and checks two promises: no masked value contains the original, and a stored token cannot be turned back into the phone number by trying every number with the same first five digits:
# Four ways to keep less of a value, tried on 1,000 made-up customers (seeded, so every run makes the same ones):
# truncate a card number to its last four digits, mask an email address and a phone number for display, and turn a
# phone number into a keyed token that can still be matched. The checks at the end are the point: no masked value may
# contain the original, and a token must not be reversible by trying every possible number.
import hashlib
import hmac
import random
import secrets
def last_four(card):
return "**** " + card[-4:]
def mask_email(email):
name, domain = email.split("@")
return name[0] + "*" * (len(name) - 1) + "@" + domain
def mask_phone(phone):
return "*" * (len(phone) - 2) + phone[-2:]
KEY = secrets.token_bytes(32) # held outside the database, in a secrets manager, in a real app
def keyed_token(phone):
return hmac.new(KEY, phone.encode(), hashlib.sha256).hexdigest()
def plain_hash(phone):
return hashlib.sha256(phone.encode()).hexdigest()
rng = random.Random(7) # test data only: never use the random module for anything secret
people = []
for i in range(1000):
card = "".join(str(rng.randrange(10)) for _ in range(16))
phone = "98765" + "".join(str(rng.randrange(10)) for _ in range(5))
people.append({"card": card, "email": f"customer{i}@mail.example", "phone": phone})
print("customer 0, as stored :", people[0]["card"], people[0]["email"], people[0]["phone"])
print("customer 0, as shown :", last_four(people[0]["card"]), mask_email(people[0]["email"]), mask_phone(people[0]["phone"]))
leaks = sum(p["card"] in last_four(p["card"]) or p["phone"] in mask_phone(p["phone"]) or p["email"] in mask_email(p["email"]) for p in people)
print("masked values that still contain the original:", leaks, "of", len(people))
same = keyed_token(people[0]["phone"]) == keyed_token(people[0]["phone"])
print("the same phone gives the same keyed token, so duplicates can be found:", same)
# Can the stored form be turned back into the number by trying every number that starts 98765?
target = people[0]["phone"]
weak = plain_hash(target)
tries = next(n for n in range(100_000) if plain_hash(f"98765{n:05d}") == weak) + 1
print(f"plain SHA-256: found the number again after {tries:,} of 100,000 tries")
stored = keyed_token(target)
guessed_key = secrets.token_bytes(32)
hits = sum(hmac.new(guessed_key, f"98765{n:05d}".encode(), hashlib.sha256).hexdigest() == stored for n in range(100_000))
print(f"keyed token, tried without the key: {hits} matches in 100,000 tries") Output
customer 0, as stored : 5260181590830166 customer0@mail.example 9876513186 customer 0, as shown : **** 0166 c********@mail.example ********86 masked values that still contain the original: 0 of 1000 the same phone gives the same keyed token, so duplicates can be found: True plain SHA-256: found the number again after 13,187 of 100,000 tries keyed token, tried without the key: 0 matches in 100,000 tries
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 keep_less.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
None of the 1,000 masked values held its original. The last two lines are the reason to key the hash. A plain SHA-256 of the phone number gave the number back after 13,187 tries, because the program only had to hash the 100,000 numbers that start with 98765 and compare. Without the key, 100,000 tries found nothing. A plain hash of a small set of values, such as phone numbers, PINs or dates of birth, is a table anyone can rebuild, not a protection.
Which technique fits depends on what the feature needs. Answer three questions about a field of yours:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Protection | Original back? | Match equal values? | A stolen database reveals |
|---|---|---|---|
| Do not keep it What is stored: nothing | Poor gone for good, which is the point | Poor nothing is left to compare | Good nothing at all |
| Truncate What is stored: a part, such as the last four digits | Poor the part you cut is gone | Mixed only on the kept part, which many values share | Good the kept digits only |
| Mask on screens What is stored: the full value, shown obscured | Good stored whole; the mask is only on screens | Good the stored value matches exactly | Poor the full value: masking protects screens, not the database |
| Tokenise What is stored: a random token; the value sits in a vault | Good only through the vault, with its own access control | Mixed only if the vault gives one value the same token every time | Good random tokens that mean nothing without the vault |
| Keyed hash What is stored: an HMAC made with a key kept elsewhere | Poor cannot be reversed, by design | Good equal values give equal tokens while the key stays the same | Good nothing usable: 100,000 tries without the key found nothing |
| Encrypt the field What is stored: ciphertext; the key is kept elsewhere | Good with the key, which lives outside the database | Mixed only by decrypting, or with a keyed hash stored beside it | Good ciphertext, as long as the key was not stolen with it |
How the answers decide
- “Does any feature need the original value back, such as charging a card or calling a phone number?” No, nothing reads it back: rules out Mask on screens, Tokenise and Encrypt the field. Keeping a value that no feature reads back only adds a copy to lose. Drop it, truncate it, or keep a keyed hash if you must match it.
- “Does any feature need the original value back, such as charging a card or calling a phone number?” Yes, a feature uses it: rules out Do not keep it, Truncate and Keyed hash. These throw the original away for good, so a feature that needs it back cannot use them.
- “Do you need to find equal values, such as a phone number registered on two accounts?” Yes, we compare values: rules out Do not keep it and adds a caveat to Truncate, Tokenise and Encrypt the field. Truncated values collide, and comparing tokens or ciphertext needs the vault, the key, or a keyed hash kept beside them.
- “Do people see this value on screens, such as support agents or the customer?” Yes, people see it: adds a caveat to Tokenise and Encrypt the field. Store it this way and still show it masked by default, with an explicit action to reveal it, as ASVS 14.2.6 asks.
- “Do people see this value on screens, such as support agents or the customer?” No, only code uses it: rules out Mask on screens. Masking protects what people see. If nobody sees the value, a mask changes nothing about the stored copy.
What you keep can still leave through URLs, logs, caches, browser storage, third parties, exports, backups and file metadata, and each copy needs a retention rule, which India’s DPDP Act makes a legal duty.
Checkpoint
Check: Truncate, mask, tokenise, keyed hash
Where sensitive data leaks, traced with a data map Pro: opens with a Pro, Premium or Ultimate pass
Most leaks are copies nobody planned: a value in a URL, a log line or a cached page. This part follows one value through the places that copy it, then checks twelve flows of a small shop against its data map.
In this part:
- A value in a URL is copied into at least five places
- Twelve flows checked against a data map, and what failed
- Caches, browser storage and logout leave copies behind
1 diagram · 1 runnable example (JavaScript) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Encrypting what you keep, with a key outside the database Pro: opens with a Pro, Premium or Ultimate pass
Encryption at rest protects a copy of the data only when the key is not in the same copy. This part encrypts one column with envelope encryption, reveals a value through a key service that records it, and changes the key without re-encrypting the data.
In this part:
- Disk encryption stops a stolen disk, not a breach through the application
- Envelope encryption: a data key per row, wrapped by a key you never store
1 runnable example (JavaScript) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Retention and deletion, with India's DPDP Act Pro: opens with a Pro, Premium or Ultimate pass
Data that is kept forever leaks eventually. This part gives every data class a retention rule, runs a sweep with an injected clock, and maps these habits onto the duties of India’s Digital Personal Data Protection Act.
In this part:
- Every class gets a retention rule, and a sweep enforces it
- What India’s DPDP Act asks of engineers
1 diagram · 1 runnable example (Python) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: a profile that shows only what each reader needs
Write the masking functions and a profile response that gives each kind of reader only the fields their task needs. The sample tests run in your browser:
Exercise · Easy · JavaScript
A profile that shows each reader only what they need
A shop's profileFor(user, viewer) in profile.mjs returns the whole customer record to everyone. Write three masks and make the profile return only what each reader needs:
maskCard(card)keeps the last four digits:'4111 1111 1111 1234'becomes'**** 1234'. Ignore spaces and dashes in the input.maskEmail(email)keeps the first character of the name and the whole domain:'asha@mail.example'becomes'a***@mail.example'. A one-character name becomes a single*.maskPhone(phone)keeps the last two digits and turns every other digit into*, ignoring any spaces or+:'+91 98765 43210'becomes'**********10'.profileFor(user, viewer)takes a user{ name, email, phone, card, birthDate, address, city }and a viewer{ role, reveal }:role: 'self':{ name, email, phone, address, card }, with the card masked; nobody sees a full card number.role: 'support':{ name, email, phone, card }, all masked except the name, plusrevealed: []. A field named inviewer.revealis shown in full and listed inrevealed, but only'email'and'phone'can be revealed; the card stays masked whatever is asked.role: 'analytics':{ city }and nothing else.- Any other role gets an
Error.
Starter code · profile.mjs
// Starter: every reader gets the whole record. Write the masks and return only what each role needs.
export function maskCard(card) {
return card;
}
export function maskEmail(email) {
return email;
}
export function maskPhone(phone) {
return phone;
}
export function profileFor(user, viewer) {
return { ...user };
} The sample tests · profile.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { maskCard, maskEmail, maskPhone, profileFor } from './profile.mjs';
const asha = {
name: 'Asha Rao',
email: 'asha@mail.example',
phone: '+91 98765 43210',
card: '4111 1111 1111 1234',
birthDate: '1994-02-11',
address: '12 Lake Road, Pune',
city: 'Pune',
};
test('the masks keep only what is shown', () => {
assert.equal(maskCard('4111 1111 1111 1234'), '**** 1234');
assert.equal(maskCard('4111-1111-1111-9876'), '**** 9876', 'dashes are ignored');
assert.equal(maskEmail('asha@mail.example'), 'a***@mail.example');
assert.equal(maskEmail('k@mail.example'), '*@mail.example', 'a one-character name');
assert.equal(maskPhone('+91 98765 43210'), '**********10');
assert.equal(maskPhone('0123'), '**23');
});
test('the customer sees their own data, but never the full card', () => {
assert.deepEqual(profileFor(asha, { role: 'self' }), {
name: 'Asha Rao',
email: 'asha@mail.example',
phone: '+91 98765 43210',
address: '12 Lake Road, Pune',
card: '**** 1234',
});
});
test('support sees masked values unless a field is revealed', () => {
assert.deepEqual(profileFor(asha, { role: 'support' }), { name: 'Asha Rao', email: 'a***@mail.example', phone: '**********10', card: '**** 1234', revealed: [] });
const shown = profileFor(asha, { role: 'support', reveal: ['phone', 'card', 'birthDate'] });
assert.equal(shown.phone, '+91 98765 43210', 'phone can be revealed');
assert.equal(shown.card, '**** 1234', 'the card stays masked');
assert.equal(shown.birthDate, undefined, 'fields support never sees cannot be revealed');
assert.deepEqual(shown.revealed, ['phone']);
});
test('analytics gets the city and nothing else', () => {
assert.deepEqual(profileFor(asha, { role: 'analytics' }), { city: 'Pune' });
});
test('no response contains the full card number, and unknown roles are refused', () => {
for (const role of ['self', 'support', 'analytics']) {
const text = JSON.stringify(profileFor(asha, { role, reveal: ['card', 'email', 'phone'] }));
assert.ok(!text.includes('1111'), `the ${role} response holds no hidden card digits`);
}
assert.throws(() => profileFor(asha, { role: 'marketing' }), undefined, 'an unknown role');
const later = profileFor({ ...asha, taxId: 'TX-77810' }, { role: 'self' });
assert.equal(later.taxId, undefined, 'a field added later is not sent by default');
}); A hint
Build each response from the list of fields its role may see, never by copying the user and deleting fields: a field added to the user record later must not appear in any response unless someone adds it to a list.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The self-check below asks about classifying and minimising, starting with whichever of the two your answers show you know less well:
Practice that adapts to you
Practise this lesson
6 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher to mid · Warm-up
What is data minimisation, and why does it count as a security control?
Data minimisation means collecting, keeping and showing only the data a task needs. It counts as a security control because data you do not have cannot be stolen, leaked or misused, which is how the OWASP Top 10 puts it. It has three moves: collect less, keep less and show less. Four techniques do most of the work: truncate a value you never need in full, such as keeping the last four digits of a card; mask what people see, with an explicit action to reveal it; tokenise through a vault, so the database holds random tokens; and keep a keyed hash when you only need to match equal values. Each removes copies of the original, so a breach of any one store reveals less.
-
Fresher to senior
A support tool shows each customer's full card number, date of birth and address to 200 agents. Redesign what the screen shows and what the API behind it returns, without making support slower.
-
Mid to senior
Marketing wants the full user profile, email and phone included, sent to a third-party analytics tool so they can build audiences. How do you respond, and what would you send instead?
-
Mid to staff
Design retention for a food-delivery app's order history, saved addresses, support chats and access logs, for a team that deletes nothing today. What rules do you set, and how do you enforce them?
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What four protection levels does the lesson use, and what do they decide?
Public, internal, confidential and restricted. The level decides where a field may go: logs, third parties and screens. Restricted data (card numbers, passwords, session tokens, API keys) is never logged, never sent to third parties and never shown in full; confidential data (email, phone, address, date of birth) is logged masked only.
-
What do ASVS 5.0.0 14.1.1 and 14.1.2 ask for, and what is the trap with encoded data?
14.1.1: identify sensitive data and sort it into protection levels. 14.1.2: write rules for each level covering encryption, integrity, retention, logging and access to those logs. Data that is merely encoded, such as Base64 or the readable payload of a JWT, is still data in the clear and keeps the level of what it holds. Treat an unclassified field as confidential until someone decides.
-
What is the cheapest protection for sensitive data?
Not having it: collect less, keep less and show less, because data that is not kept cannot be stolen (A04:2025). What you do keep can still leave through URLs, logs, caches, browser storage, third parties, exports, backups and file metadata, so each copy needs a retention rule, which India's DPDP Act makes a legal duty.
-
How do truncating, masking, tokenising and keyed hashing differ?
Truncate: keep only part of a value, such as the last four card digits. Mask: keep the value but show it obscured, with an explicit action to reveal it (ASVS 14.2.6). Tokenise: replace it with a random token and keep the real value in a separate vault. Keyed hash: store an HMAC with a key held outside the database, when you only need to match equal values.
-
Why does hashing a phone number with plain SHA-256 not protect it?
The set of phone numbers is small. The lesson's program only had to hash the 100,000 numbers that start with the same five digits and compare, and found the original after 13,187 tries; against a keyed hash, 100,000 tries found nothing. A plain hash of phone numbers, PINs or dates of birth is a table anyone can rebuild.
Key takeaways
- Give every field one of four levels with written rules; encoded data, such as a JWT payload, keeps its level.
- Data you do not keep cannot be stolen: collect less, keep less and show less before protecting anything.
- Truncate what you never need in full, mask what people see, tokenise through a vault, and key the hash you match on.
- Test masking as a property: none of 1,000 masked values contained its original.
- An unkeyed hash of a phone number came back after 13,187 tries, so a hash of a small set of values needs a key.
Terms in this lesson
- data minimisation
- Collecting, keeping and showing only the personal data a task needs, for only as long as it needs it. Data that is not kept cannot be stolen or leaked.
- keyed hash
- A hash computed with a secret key, such as HMAC-SHA-256. Equal inputs give equal outputs while the key stays the same, but without the key nobody can rebuild the values by hashing guesses.
- tokenisation
- Replacing a sensitive value, such as a card number, with a random token and keeping the real value in a separate vault with its own access control. The token reveals nothing on its own.
References
- A04:2025 Cryptographic Failures (OWASP Foundation)
- ASVS 5.0.0, V14 Data Protection (OWASP Foundation)
- Cryptographic Storage Cheat Sheet (OWASP Foundation)
- User Privacy Protection Cheat Sheet (OWASP Foundation)
- Forgot Password Cheat Sheet (OWASP Foundation)
- Clear-Site-Data header (MDN Web Docs (Mozilla))
- Referrer-Policy header (MDN Web Docs (Mozilla))
- The Digital Personal Data Protection Act, 2023 (No. 22 of 2023) (Ministry of Electronics and Information Technology, Government of India)
- Digital Personal Data Protection Rules, 2025 (G.S.R. 846(E)) (Ministry of Electronics and Information Technology, Government of India)
- hmac: Keyed-Hashing for Message Authentication (Python 3.14 documentation) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress