Sensitive Data Exposure

Sensitive data exposure is the defect in which data that should stay private becomes readable by someone who should not see it: a session token in a URL, a customer’s national ID in an API response, a card number in a log file, a stack trace on an error page. Nobody breaks in; the application hands the data out, and every copy — browser history, access logs, screenshots, the Referer header — travels further than anyone planned.

The name comes from older editions of the OWASP Top 10. Since the 2021 edition the list names root causes instead: Cryptographic Failures for data stored or sent without protection, Security Misconfiguration for verbose errors and debug settings and, in the 2025 edition, Mishandling of Exceptional Conditions. The OWASP API Security Top 10 2023 covers responses that return too many properties under API3:2023, Broken Object Property Level Authorization. Under every name the tester’s question is the same: what does the system reveal, to whom, and where does the copy end up?

Why it matters

For the product, a leak is often the whole breach: an access log full of session tokens, or a list endpoint that returns every customer’s phone number to any signed-in user, needs no exploit chain. Personal data also has legal weight. Saudi Arabia’s Personal Data Protection Law (PDPL) applies to personal data processed in test environments and log files as much as in production, and requires the data collected to be limited to the minimum the purpose needs; a field nobody uses is a liability, not a feature.

For the tester, these are among the easiest security defects to find: no attack tools, only reading what the application already sends and stores, which a functional tester sees every day. The tester is also a possible source of exposure, through screenshots, bug reports and test data copied from production, so this lesson is about finding exposure without causing it.

What counts as sensitive, and why encoding is not protection

Start by naming the data. Four classes matter in almost every product, each with its own rule about where it may appear, and they overlap: a password-reset link is a secret, and the e-mail address it was sent to is personal data.

Whatever the class, encoding is not protection. Base64, percent-encoding, hex and the payload of a JWT are reversible by anyone who holds the string, as the Base64 Decoder and the JWT Decoder show in a second. Only encryption with a key the reader lacks, or a one-way hash for passwords, protects data; the next lesson, on cryptography basics, explains the difference. Masking is a display rule, not protection: **** 4242 is safe only when the server never sends the full number.

  • Credentials and secrets: passwords, session identifiers, access and refresh tokens, reset and verification codes, API keys, private keys. Rule: never shown again once entered or issued, never logged; never in a URL, except a random, single-use, short-lived token such as the one in an e-mailed reset link.
  • Personal data: anything that identifies or describes a person — a name with a phone or e-mail, national ID or iqama number, date of birth, address, location, photos. The PDPL defines a stricter class, sensitive data, which includes health data, genetic and biometric data, and data revealing ethnic origin or religious belief. Rule: only to the person or an authorised role, and only the fields the screen needs.
  • Payment data: card number, expiry date, card verification code, IBAN. Rule: the number masked everywhere, the verification code never stored or returned.
  • Internal details: stack traces, SQL text, file paths, internal hostnames and IP addresses, software versions, debug flags. Rule: a generic message to the user, the details in the server log under a reference id.

Try it: Base64 Decoder Try it: JWT Decoder

Where data leaks: URLs, responses, error messages and logs

URLs are made to be shared, and RFC 9110 warns against carrying sensitive information in them for that reason. A query string in a GET request is copied to the browser history and bookmarks, to the access logs of the web server, load balancer and CDN, to analytics, support screenshots and any third-party script on the page, which can read the full address, and to other sites in the Referer header when the page’s Referrer-Policy allows the full URL. So ?sessionId=, ?email= and ?nationalId= in links, filters and e-mailed buttons are leaks even over HTTPS, which protects the connection, not the copies. Personal data and secrets belong in a request body or a header; the usual accepted exception is a random, single-use, short-lived token, such as the one in a reset or confirmation link, opened on a page that sends Referrer-Policy: no-referrer.

Responses leak when the server returns the object instead of the view. The profile screen shows a name, but /api/users/42 carries passwordHash, otpSecret, nationalId and isAdmin; a list endpoint returns every customer’s phone to any signed-in user; a JavaScript bundle ships an API key; a response with personal data and no Cache-Control: no-store can stay in the browser cache, as the lesson on HTTP caching explains. The check is always the raw body, never the screen.

Error messages leak internals. A 500 page with a stack trace, a database error with the SQL text, a file path, a framework version or a debug page left on in test tells an attacker exactly what to try, and a login that answers "user not found" to one address and "wrong password" to another confirms which accounts exist. The user should see a generic message with a reference id; the detail belongs in the server log.

Logs leak quietly. An application that logs whole request bodies logs every password and card number sent to it; access logs keep every URL from the first paragraph; error trackers, analytics and crash reporters are third parties receiving copies; and log snippets pasted into chat and tickets travel further still. The OWASP Logging Cheat Sheet lists data to keep out of logs, among them passwords, session identifiers, access tokens, encryption keys, payment card data and sensitive personal data such as health data and government identifiers.

Four places sensitive data leaks, where each copy of the data ends up, and the read-only check that finds the leak without attacking anything.
Where it leaksWhere the copy ends upSafe check
URL and query string?sessionId=, ?phone= in links, filters and e-mailed buttonsBrowser history and bookmarks, web server and CDN access logs, analytics and other third-party scripts on the page, screenshots, and other sites through Referer when the policy allowsFilter the Network panel for ?, open every e-mailed and SMS link, parse suspect URLs. Expected: personal data and secrets in a body or a header; an e-mailed link carries only a single-use, short-lived token
API and page responsespasswordHash, nationalId, another person’s phone, keys in a JavaScript bundleBrowser cache without no-store, the app’s own storage, every other app that calls the same endpoint, exports and screenshotsRead the raw body of every endpoint a screen calls and mark each field the screen does not show; include list, search, export and "me" endpoints
Error messagesStack trace, SQL text, file path, version; "user not found" vs "wrong password"The user’s screen, support tickets and error trackers, and the notes of anyone probing the systemOne malformed value per input type, a missing field, an unsupported method; read every 4xx and 5xx body. Expected: a generic message with a reference id, identical login and reset replies
Logs and monitoringWhole request bodies with passwords or card numbers, Authorization headers, every URL from the first rowLog aggregators and their backups, third-party error and analytics services, chat messages and tickets where snippets are pastedWith the developer, search the test environment’s logs for your own test values with a regular expression; ask which fields are logged and how they are masked

A leak is a finding to report with the values masked; whether it can be exploited is for the security team to judge.

Try it: URL Parser

Data minimisation: the question behind every field

Data minimisation means collecting, processing, showing and keeping only the data the stated purpose needs. The PDPL requires it, the GDPR names it as a principle, and it is the strongest defence against exposure, because data the system never holds cannot leak. For a tester it becomes one question per field, asked at four points.

  • Collection: does the form ask for data the feature does not need, such as a date of birth for a newsletter? Each extra field is a test condition and a question for the product owner.
  • Return: does every field in the response appear on some screen for this role? Compare the raw JSON with the UI, including list, search, export and "me" endpoints, which are usually the widest.
  • Logging: which fields does the application write to its logs, and which are masked? Ask the developer, then verify with your own test values.
  • Retention: after a user deletes an account, does the data still appear in search, exports and log backups? Does the test environment hold a copy of production data at all? Under the PDPL that copy is personal data being processed; synthetic data is the default, as the database testing guide explains.

Worked example: an order-tracking API

A delivery app’s test plan includes an exposure check of its new order tracking, with the scope confirmed in writing by the security lead: the test environment, the tester’s own test customer and test courier accounts, harmless inputs only. Customers receive a tracking link by SMS, and the tracking page and the mobile app both call GET /api/orders/{id}.

The link reads https://track.shop.example/t?order=1042&phone=0551234567: a phone number in a GET URL, copied to the SMS provider’s logs, the browser history and the map provider’s script that the tracking page embeds, which can read the full address. The response below, read as raw JSON, carries the full card number and verification code, the customer’s national ID, the courier’s home address, an internal note and a database host, none of which the tracking screen shows, and it has no Cache-Control header. /api/orders/abc returns a 500 whose body names a file path and the SQL text. Finally a developer, asked what the API logs, pastes a line from the application log that contains a complete POST /login body, password included.

The tester reports, with every value masked, in severity order: first the card data in the response, because the verification code must not be kept once the payment is authorised, and the password in the log, readable by everyone with log access; then the phone number in the URL, the national ID, the courier’s address and the internal note, the SQL text and file path in the error body, and the missing Cache-Control: no-store. Severity is agreed with the security team, and the fix is confirmed everywhere the data appeared, not only on the tracking screen.

GET /api/orders/1042 HTTP/1.1
Host: api.test.shop.example
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3NzMxIn0.x

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 1042,
  "status": "out_for_delivery",
  "customer": { "name": "Test Customer", "phone": "0551234567",
                "nationalId": "1098765432" },
  "payment": { "card": "4242424242424242", "expiry": "12/27", "cvv": "123" },
  "courier": { "name": "Test Courier", "phone": "0509876543",
               "homeAddress": "Al Olaya, Riyadh" },
  "internal": { "note": "complained twice", "dbHost": "10.0.3.12" }
}

GET /api/orders/abc HTTP/1.1
Host: api.test.shop.example

HTTP/1.1 500 Internal Server Error
Content-Type: application/json

{ "error": "invalid input syntax for type integer: \"abc\"",
  "query": "SELECT * FROM orders WHERE id = 'abc'",
  "at": "/srv/app/src/orders/service.ts:88" }

Try it: JSON Tree Viewer

How to test for exposure safely

Exposure testing is reading, not attacking: you look at what the application sends and stores for your own test accounts, in a system you own or have written permission to test. Real customer data is never test data; if you meet it by accident, stop, do not copy it and tell your lead. Keep evidence with values masked, such as 05512*****.

  • Inventory: from the requirements and data model, list the feature’s sensitive fields and, for each, who may see it and where it may appear: screen, API, log, export, e-mail, SMS.
  • URLs: walk every flow with the Network panel filtered for ?, open every e-mailed and SMS link and parse suspect URLs. Expected: no personal data, passwords, session identifiers or keys in any query string, and only a single-use, short-lived token in an e-mailed link.
  • Responses: for every endpoint a screen calls, read the raw body and mark each field the screen does not show; repeat for list, search, export and "me" endpoints, HTML source and JavaScript bundles.
  • Errors: send one malformed value per input type, a missing required field and an unsupported method, and read the 4xx and 5xx bodies; login, registration and reset must answer identically for existing and unknown accounts.
  • Logs and third parties: with the developer, search the test environment’s application and access logs for your own test values with a regular expression, and list which error trackers and analytics receive requests.
  • Client side: Cache-Control: no-store on responses with personal data, the Referrer-Policy header, the contents of local and session storage, and what the browser console prints.

Try it: Regex Tester Try it: AI API Response Risk Analyzer

Common mistakes

  • Judging exposure by the screen instead of the raw response: the UI masks the card number while the JSON carries it in full.
  • Treating Base64, hex or a JWT payload as hidden; it is encoding, and anyone holding the string can read it.
  • Pasting live tokens, full identifiers or a customer’s data into a bug report that everyone can read: the report becomes a new leak.
  • Checking only the happy path’s responses and never the error bodies, the list and export endpoints, the e-mails and the SMS links.
  • Accepting "it is only the test environment": debug settings and logging configuration travel to production through the same pipeline, and production data copied into test is itself exposure.
  • Confusing the field question with the object question: access control asks whether this user may see this order; exposure asks which of its fields this user should receive, and which no one should.

The QA perspective

On a real team, exposure testing starts at refinement. For every new field, ask who sees it, where it may appear — screen, API, log, export, e-mail, SMS — and how long it is kept, and write the answers as test conditions. Ask the developer whether the response is built from a view model, an object that holds only the fields the screen needs, or from the raw database entity, and which fields are logged and masked, and get the scope and the safe inputs from the security lead in writing before you start.

Record each finding as endpoint, field, who received it, where the copy ends up and the masked evidence, with restricted visibility, and agree severity with the security team, who weigh the data class, the audience and the number of copies. After the fix, confirm it everywhere the value appeared — response, cache, log, export — and keep a regression check: a contract test that validates the response against a JSON Schema with additionalProperties: false on every object fails the day a new field slips into the payload, at any depth.

Practice exercise

Review a clinic’s appointment confirmation for exposure

Your task

You have written permission to test the test environment of a clinic booking app with your own test patient account. The material: the confirmation link from the e-mail, the response the "My appointments" page loads, and an error body. List every exposure, say where each value can be copied to, classify the data and propose the minimal version of the response.

Then write the first bug title and name the checks you would do next.

Material
E-mail link:
https://clinic.example/appointments/confirm?id=5581&patient=1098765432&otp=482913

GET /api/patients/me/appointments HTTP/1.1   (signed in as patient 7731)

HTTP/1.1 200 OK
Content-Type: application/json

[{
  "id": 5581, "date": "2026-10-09T09:30:00+03:00", "status": "confirmed",
  "patient": { "id": 7731, "name": "Test Patient", "nationalId": "1098765432",
               "insuranceNo": "INS-44120", "diagnosisHistory": ["..."] },
  "doctor": { "name": "Dr. Test", "mobile": "0551112222", "licenceNo": "D-9981" },
  "clinic": { "name": "Olaya branch", "dbHost": "10.20.0.5", "env": "test" }
}]

GET /api/patients/me/appointments?date=next-week HTTP/1.1

HTTP/1.1 500 Internal Server Error
{ "error": "invalid input syntax for type date: \"next-week\"",
  "at": "/srv/clinic/api/appointments/list.js:41" }
Steps
  1. Classify every value in the three samples: secret, personal data, sensitive personal data, payment data or internal detail.
  2. For the e-mail link, list where the URL will be copied.
  3. Compare the response fields with what the appointments page needs and mark each extra field.
  4. Read the error body and decide what the user should see instead.
  5. Write the findings in severity order, with values masked.
Show the model answer
Model answer

Link: patient= is a national ID (personal data) and otp= a one-time code (secret) in a GET URL, copied to mail servers, the browser history, the clinic’s access logs, any third-party script on the confirmation page and, if the Referrer-Policy allows full URLs, the Referer sent to other sites. Expected: no national ID and no OTP in the link, only an opaque, random token that works once for this confirmation and then expires, with Referrer-Policy: no-referrer on the confirmation page.

Response: nationalId and insuranceNo (personal data) and diagnosisHistory (health data, which is sensitive personal data under the PDPL) are not needed to list appointments; the doctor’s mobile is another person’s personal data and the licence number is unneeded; clinic.dbHost and env are internal details; no payment data appears. Minimal: id, date, status, doctor name, clinic name. The response also needs Cache-Control: no-store.

Error: it reveals the database engine’s message and a file path, and shows that the input reached the database unvalidated; the user should get a 400 with "invalid date" and a reference id, the details in the server log.

Severity order, values masked: first the national ID 10987***** and the OTP ****** in the link, because the link reaches mail servers and third parties while the response reaches only the signed-in patient; then diagnosisHistory, nationalId and insuranceNo in a response without no-store; then the doctor’s mobile 05511*****; last the internal details in the response and the error body.

Bug title: "Appointment confirmation link carries the patient’s national ID and OTP in the query string". Proposed severity: critical, to be agreed with the security team.

Next: search the server log for the OTP and the national ID with the developer, check the appointments export, PDF and SMS text, and read the response of the doctor-facing app.

Frequently asked questions

Is sensitive data exposure still an OWASP Top 10 category?

Not under that name. The 2021 edition renamed it Cryptographic Failures to point at a root cause, and verbose errors and debug settings fall under Security Misconfiguration. The OWASP API Security Top 10 2023 covers over-wide responses under Broken Object Property Level Authorization. The leaks are the same; only the labels moved.

Is it a problem that the API returns my own data to me?

Not by itself; the question is whether every returned field is needed by that screen. Your national ID in an appointment list, or your full card number in an order, leaks through caches, screenshots and logs even though you are the owner, so data minimisation applies to your own data too.

What do I do if I find real customer data in the test environment?

Stop, do not copy or screenshot it, and tell your lead or data protection officer. Under the PDPL a production copy in test is still personal data being processed. Ask for synthetic or anonymised test data before continuing, as the database testing guide describes.

A JWT holds the user’s e-mail and role. Is that exposure?

The payload of an ordinary signed JWT is only Base64url-encoded, not encrypted, so anyone who holds the token can read it. A role claim is normal; a national ID, phone number or password in the payload is exposure. Where the token is kept matters too: in local storage, any script on the page can read it. Decode test tokens with the JWT Decoder and check the claims against the data inventory.

References

  • OWASP Top 10:2025OWASP Foundation · 2025The ten most critical web application security risks, with how to test for and prevent each.
  • OWASP API Security Top 10 (2023)OWASP Foundation · 2023The ten most common API weaknesses: broken object-level authorization, broken authentication, unrestricted resource consumption and more.
  • OWASP Cheat Sheet Series: LoggingOWASP FoundationWhat to log and what never to log (passwords, tokens, personal data), and how to keep logs tamper-evident.
  • Personal Data Protection Law (English translation)Saudi Data & AI Authority (SDAIA) · 2023The Saudi law governing personal data — why test environments should use synthetic data rather than copies of real customer records.

Practise with Mutqan tools

Break a URL into protocol, host, port, path, query parameters, fragment and credentials.

Web

Explore JSON as a collapsible tree with paths, types and value counts.

JSON & Data

Decode JWT header and payload, inspect claims and check expiration — entirely in your browser.

Security

Decode Base64 or Base64url strings to text, with automatic padding repair.

Encoding

Test regular expressions live with match highlighting, capture groups and replace preview.

Utilities

Assess security and privacy risks in an API response: PII exposure, verbose errors, insecure headers, enumeration hints.

Bug Analysis AI

Go deeper

Your progress on this topic

Part of the QA Roadmap