Input Validation and Output Encoding

Input validation checks every value that enters an application against the rules of its field and rejects what does not fit, on the server, before the value is used. Output encoding converts a stored value into the form that is safe for the place it is written to, so that a browser, a URL parser or a database treats it as data, never as code: in an HTML page < becomes &lt;, in a URL a space becomes %20, and a database receives the value as a query parameter.

Example: a customer called O'Brien & Sons is valid input that no rule should reject. The apostrophe and the ampersand become dangerous only at the exit, if a page or a query is glued together from text. Validation decides what gets in; encoding decides how it is written out. An application needs both, and a tester checks both.

Why it matters

Injection and cross-site scripting (XSS), which the next two lessons test in detail, share one root cause: a value from outside reaches an interpreter such as a database, a shell or a browser and runs as code. Injection is one of the OWASP Top 10:2025 categories and includes XSS, and the two controls that prevent it are exactly these: validate what comes in, encode what goes out. One unchecked entry point or one unencoded output is enough.

For a tester this is productive ground, because the defects hide in the gaps between layers: a form that validates while the API does not, a list page that encodes while the export does not. Functional tests with tidy values never reach those gaps. A few harmless marker values, carried from every entry point to every place the value appears, find them in minutes, without attack tools, on systems you are authorised to test.

Input validation: allow-list rules, enforced on the server

The OWASP Input Validation Cheat Sheet asks for two checks on every value: its syntax, the expected type and format (type, length, range, format, membership of a fixed set), and its semantics, whether the value makes sense for the operation: the visit date is not in the past, the quantity does not exceed the stock. Testers usually call them syntactic and semantic validation. Rules are written as allow-lists: define what is accepted and reject everything else. A block-list (deny-list) of forbidden strings is always incomplete, because a dangerous value can be spelled in more ways than any list holds, and it rejects honest data: blocking the apostrophe rejects O'Brien without making any query safe. The cheat sheet lists deny-listing and "cleaning" input among its common pitfalls. Free text has an allow-list too, only a wider one: one character encoding agreed across components, malformed input rejected, legitimate punctuation and scripts kept, a length limit, and, where values are compared, one Unicode normalisation applied before validation and storage (so that é typed as one character or as e plus an accent mark is the same value).

Browser checks such as required, maxlength and a pattern are feedback, not a control: as in the lesson on testing web forms, anyone can edit the page or send the request without it. The cheat sheet is explicit: validate on the server even when the browser checks the same fields, and validate data from internal APIs, partner feeds and queues too. The form is only one entry point: the JSON API the mobile app calls, a bulk import, a query string, a header such as Accept-Language, a cookie and a webhook all bring values from outside. The tester proves that every path enforces every rule.

  • Write each rule so that it can be tested: ^ORD-[0-9]{5}$ for an order number, ORD- and exactly five digits (the anchors ^ and $ make the whole value match, as the cheat sheet asks; without them the pattern also accepts xx ORD-12345 <b>); an integer from 1 to 20 for a quantity; one value from the stored list for a country; for a company name 2 to 80 characters of Unicode letters, digits, spaces and ' - & ., so the apostrophe and the ampersand stay legal.
  • Copy the form's request from the Network panel as cURL, change one field to a value the page refuses, send it with a test account and expect a 4xx that names the field and the rule, nothing stored, and no stack trace or SQL in the message.
  • Send the same set through the API and the import: 81 characters, a quantity of 0 and of 21, country ZZ, and the request below, in which company and notes pass their rules while quantity and country fail. Any path that accepts what the form refuses is a finding.
  • Watch for silent repair: a server that cuts 81 characters to 80 or strips < without a message changes the user's data. Unless the requirement allows it, report it.
POST /api/customers HTTP/1.1
Host: crm.test.example
Content-Type: application/json

{"company": "O'Brien & Sons", "quantity": 21, "country": "ZZ",
 "notes": "Discount < 10% until <b>Ramadan</b>"}

HTTP/1.1 422 Unprocessable Content
Content-Type: application/json

{"errors": [{"field": "quantity", "rule": "integer 1-20"},
            {"field": "country", "rule": "not a known country code"}]}

Try it: Regex Tester Try it: Negative Test Generator Try it: Special Character Dataset Generator

Output encoding: one value, one encoding per context

Encoding happens at the moment of output, in the code that writes the value into a page, a link, a script or a query, and it depends on where the value lands. The OWASP Cross Site Scripting Prevention Cheat Sheet gives each HTML context its own rules: element content, attribute values, JavaScript, CSS and URLs. The database has its equivalent: a parameterised query sends the value separately from the SQL statement, so the database never parses it as SQL; the Injection Prevention Cheat Sheet treats escaping the input as a last resort. The stored value never changes, and a value encoded for the wrong context stays unsafe, because HTML entities inside a URL or a script string mean nothing there.

  • HTML element content: & becomes &amp;, < becomes &lt;, > becomes &gt;, " becomes &quot; and ' becomes &#x27; (&#39; and &apos; are the same character), so the page shows the characters as typed.
  • HTML attribute: the same entities, inside a quoted attribute such as title or value; an unquoted attribute can be broken out of with a space and many other characters. An event attribute such as onclick is a script context: the browser decodes the entities before the script runs, so they protect nothing there.
  • URL parameter: percent-encoding, so a space becomes %20, & becomes %26 and, in strict RFC 3986 encoding, ' becomes %27 (many encoders leave the apostrophe as it is); unencoded, the & in the name starts a second parameter. A URL written into href is then attribute-encoded as well.
  • JavaScript and JSON: string escaping by a maintained encoder, not quotes escaped by hand: " as \", \ as \\, plus < as \u003c when the data sits inside an HTML page, so a value cannot close the script. Safer still: keep the value out of the script, for example in a data- attribute read with dataset, and write it into the page with textContent, never innerHTML.
  • SQL: a parameterised query or prepared statement keeps O'Brien as data without touching the text.
Stored value        O'Brien & Sons

HTML content        O&#x27;Brien &amp; Sons
HTML attribute      title="O&#x27;Brien &amp; Sons"
URL parameter       ?q=O%27Brien%20%26%20Sons
JSON / JavaScript   {"company": "O'Brien & Sons"}      (nothing to escape here)
SQL                 WHERE company = ?                  (value bound as a parameter)

One character, three contexts:   <   ->   &lt; (HTML)   %3C (URL)   \u003c (script)

Try it: HTML Entity Encoder Try it: URL Encoder Try it: JSON Escape / Unescape

Why both: validation at the door, encoding at every exit

Neither control replaces the other. Validation cannot reject O'Brien, Al-Harbi & Sons or a note saying price < 50, because they are correct data; only encoding keeps them harmless on the way out. Encoding cannot reject a quantity of -1, a ten-megabyte note or a country that does not exist, because they display perfectly well; only validation keeps bad data out. The Input Validation Cheat Sheet says the same: validation does not replace parameterised queries for SQL or context-aware output encoding against XSS.

The order is fixed: input, validate (accept or reject, never repair silently), store the accepted value unchanged, encode for the context at every output. Storing an already encoded value breaks the chain, because every other output then receives wrong data: the e-mail shows &amp; as text, the CSV export carries entities, and a page that encodes correctly shows &amp; on screen because its source now holds &amp;amp;, the double-encoding defect. Sanitisation is a third, separate operation for the few fields that must accept markup, such as a rich-text editor: a sanitiser library keeps only an allow-list of tags and attributes, and the result is still encoded wherever it leaves as plain text.

The fixed order for one value: validate on the server against an allow-list, store it unchanged, encode at every output for its context; storing it encoded or encoding for the wrong context breaks the chain.
  1. 1Input arrivesAny entry point: form, JSON API, import, query string, header, cookie, webhookO'Brien & Sons
  2. 2Validate on the serverAllow-list rule per field: type, length, range, format, set, then the business meaningAllow-list

    Branch: Rejected: 4xx naming the field and the rule; nothing stored, nothing repaired silently

    Gate: Accepted values only, unchanged

  3. 3Store as validatedThe stored value never changes: no entities, nothing stripped, nothing cutO'Brien & Sons

    Branch: Defect: stored already encoded, so a page shows &amp; as text (&amp;amp; in its source) and e-mails and exports carry raw entitiesFail

  4. 4Encode at output, per contextIn the code that writes the value, at the moment it leaves
    One value, one encoding per context
    • 5HTML content or quoted attributeEntitiesO&#x27;Brien &amp; Sons
    • 6URL parameterPercent-encoding?q=O%27Brien%20%26%20Sons
    • 7JavaScript or JSON stringString escaping of " and \, and < as \u003c inside a page; nothing to escape here"O'Brien & Sons"
    • 8SQLParameterised query: the value never touches the statementWHERE company = ?

    Branch: Defect: encoded for the wrong context, such as HTML entities inside a URL or a script string, where they protect nothingFail

  5. 9OutputPage, link, script, query, e-mail, CSV export, API response: every exit, shown as typedO'Brien & Sons

Then:The browser, URL parser or database treats the value as data, never as code

Worked example: one customer through its outputs

A CRM in your test environment, with written permission to test it, has the customer form from above. The tester saves a customer with the company O'Brien & Sons and the note Discount < 10% until <b>Ramadan</b>, once through the form and once through the API with a valid quantity and country, then visits every place the values appear: the customer list, the tooltip on the name (a title attribute), the "find similar" link, the CSV export and the welcome e-mail.

  • Customer list: both values shown as typed; the source has &#x27;, &amp; and &lt;. Pass.
  • Tooltip: shows only O. The source reads title='O'Brien & Sons': the apostrophe ended the single-quoted attribute and the rest landed outside it. Finding: no attribute encoding. Stop and report; the XSS lesson continues from here.
  • Find-similar link: /customers?q=O'Brien & Sons without percent-encoding; the search runs for O'Brien and the server receives a second parameter named Sons. Finding: no URL encoding, and a functional defect for every name with an ampersand.
  • CSV export: the note arrives as Discount &lt; 10% until &lt;b&gt;Ramadan&lt;/b&gt;. Finding: the export reused HTML-encoded text, so whoever downloads it gets wrong data; encoding for the wrong context.
  • Welcome e-mail: Ramadan in bold. Finding: the template inserts the note as markup, so text written by one user is rendered in another's mailbox; report it as HTML injection with restricted visibility and let the security lead judge severity.
  • Entry points: through the API, quantity: 21 and country: ZZ return 422 naming both fields, nothing stored. Pass. Through the import, an 81-character company name is accepted and cut to 80 without a message. Finding: the import validates less than the form.

Common mistakes

  • Treating required, maxlength or a pattern in the browser as protection, and never sending the request without the page.
  • Testing the main form only, while the API, the import and the mobile client reach the same database with weaker rules.
  • Writing block-lists such as "reject <script>" instead of allow-list rules; the first spelling the list does not contain gets through.
  • Encoding before storing, or storing an already encoded value, which puts &amp;amp; into a page's source and raw entities into e-mails and exports.
  • Encoding for the wrong context, for example HTML entities inside a URL or a script string, where they protect nothing.
  • Using script payloads instead of harmless markers, or testing a system without written permission.

The QA perspective

Validation rules are test basis. In refinement, ask for every new field: type, length, range, format, the allowed set, what the user sees on rejection, and where the value will be displayed and in which contexts. Write the answers into the acceptance criteria so that developer, tester and API documentation agree, then derive the test cases with equivalence partitioning and boundary value analysis as in the test design module.

During testing, keep one marker set and carry it through every entry point to every output, including e-mails, PDFs, exports and admin screens; record request, response and page source, not screenshots alone, with tokens masked. Report a missing server check or a missing encoding as a security finding with restricted visibility, name the entry point and the exact output, and agree severity with the security lead. After the fix, confirmation-test every output and keep the markers in the regression suite: a new template, export or client reopens the gap.

Practice exercise

Review the validation and encoding of a booking form

Your task

A clinic booking site in your test environment, which you have written permission to test, has a form with Name (2 to 60 characters: letters, spaces, apostrophe and hyphen), Mobile (05 and eight digits), Reason (free text, up to 500 characters) and Slot (one of the offered times). Below are one API request with its response and three fragments of page source where the saved values appear.

Decide for each of the five observations whether it passes or is a finding: the accepted name, the accepted slot and the three fragments. Name the missing control (a validation rule, server-side enforcement, or encoding and its context) and write a bug title. Then list the next checks you would run.

Material
POST /api/bookings
{"name": "Sara <b>qa</b>", "mobile": "0512345678",
 "reason": "Follow-up, pain < 3/10 since 'Sunday'", "slot": "2026-11-03T25:00"}

HTTP/1.1 201 Created
{"id": 8821, "name": "Sara <b>qa</b>", "slot": "2026-11-03T25:00"}

Fragment 1: reception list
<td>Sara &lt;b&gt;qa&lt;/b&gt;</td>

Fragment 2: doctor's screen
<p class="reason">Follow-up, pain < 3/10 since 'Sunday'</p>

Fragment 3: reminder page
<a href="/r?name=Sara <b>qa</b>&id=8821">Reschedule</a>
Steps
  1. Compare the request's values with the four rules and read the response: which rules did the server enforce and which did it not?
  2. For each fragment, name the output context and the encoding it requires, then compare with what the source shows.
  3. Order the findings by risk and write one bug title for each.
Show the model answer
Model answer

Name: Sara <b>qa</b> contains <, > and /, which the allow-list does not permit, yet 201 Created echoes it. Finding: the server does not enforce the name rule that the form shows. Bug title: "POST /api/bookings accepts markup characters in name that the form rejects".

Slot: 2026-11-03T25:00 is not one of the offered times and hour 25 does not exist, yet it was stored. Finding: no set or semantic validation on slot. Bug title: "Booking API stores a slot that was never offered".

Fragment 1: &lt;b&gt;qa&lt;/b&gt; is correct element-content encoding; the receptionist sees the name as typed. Pass.

Fragment 2: the reason is written raw. It displays correctly only because < is followed by a space, which browsers read as text; a reason containing <b> would render as markup on the doctor's screen. Finding: missing HTML encoding of stored patient text, a probable stored XSS (input saved once and rendered for every later viewer, the subject of the XSS lesson). Bug title: "Doctor's screen shows the booking reason without HTML encoding". Report with restricted visibility and do not probe further.

Fragment 3: the href is built without percent-encoding: the space, <, > and / should be %20, %3C, %3E and %2F, and a name containing &, which gets through because the server does not enforce the name rule, would start a new parameter. Finding: missing URL encoding, also a functional defect. Bug title: "Reschedule link breaks for names with special characters".

Order: fragment 2 first, then the two validation gaps, then the link. Mobile and reason are valid in this request, so it does not show whether the server enforces their rules. Next checks: an invalid mobile number and a 501-character reason through the API, expecting a 422 that names the field; the same values through the mobile app's endpoint and the import; the confirmation e-mail and the PDF appointment letter; and the severity of each finding agreed with the security lead.

Frequently asked questions

Is client-side validation enough when the API is only used by our own pages?

No. From the browser there is no private API: whatever the page can send, a person with developer tools can send differently. The OWASP Input Validation Cheat Sheet requires validation on the server even when the browser checks the same fields; browser checks stay useful for fast feedback.

Should the application store the encoded value so that every output is safe?

No. Store the value as validated and encode at each output for its context. A stored &lt; is wrong data in an e-mail, an export or an API response, and a page that encodes correctly shows it as &lt; on screen, because its source then holds &amp;lt;. Rich text is the exception, handled by a sanitiser rather than by encoding.

What is the difference between validation, sanitisation and encoding?

Validation accepts or rejects a value against rules and changes nothing. Sanitisation changes the value by removing or altering parts, such as tags a rich-text field may not contain. Encoding changes only the representation for one output context, and the stored value stays the same. Tests look for a missing one of the three, and for sanitisation that happens silently where a rejection was expected.

Can I find these defects without security tools?

Yes. Harmless markers such as an apostrophe, an ampersand, angle brackets around a word, an over-long string and an out-of-range number, plus developer tools and a command-line client to send requests without the page, find missing checks and missing encoding. Proving that a weakness can be exploited is the security team's job; a missing control is already a defect worth reporting.

References

Practise with Mutqan tools

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

Utilities

Generate negative test cases for form fields and APIs: empty, null, wrong type, too long, injection, invalid format.

Test Design

Escape HTML special characters (<, >, &, quotes) into entities to prevent XSS.

Encoding

Percent-encode text for safe use in URLs, query strings and form data.

Encoding

Escape text for safe embedding inside a JSON string, or unescape a JSON string literal.

JSON & Data

Go deeper

Your progress on this topic

Part of the QA Roadmap