An input validation bug does not look like an attack. It looks like an order for minus five items, a discount code that was never issued, or a customer who reached the “order confirmed” page without ever paying. Nothing in the request is malformed. There is no quote character, no script tag, no SQL. The application simply did what it was told, because nobody ever told it what a sensible request looks like.
ASVS chapter V2 Validation and Business Logic covers exactly that gap, and four of its requirements apply at Level 1. This part closes all four with code you can run. Three of them are code changes, shown as a broken version next to a fixed version, and the fourth is a document you commit to your repository.
Everything below exists in both Node.js with Fastify and Go with Fiber. Pick your stack once and the whole article follows it. If you have not read Part 2 yet, it covers encoding, which is the control that stops injection. This part is about the different question of whether the input should have been accepted at all.
Conceptual Overview
Three ideas carry this whole chapter.
Validation asks whether the input is allowed. Encoding, from Part 2, asks how to write data out safely. They are separate jobs and neither replaces the other. A quantity of -5 contains no dangerous character, so encoding it changes nothing, and it will still hand a refund to whoever ordered it. Validation is the layer that rejects it.
Positive validation beats negative validation. A denylist names the values you refuse and accepts everything else. An allowlist names the values you accept and refuses everything else. Only the allowlist is safe, because you can write down every country you ship to, but you cannot write down every string that is not a country. ASVS uses the words “positive validation against an allow list”, and every check in this article is one.
There are two kinds of rule to write down, and Level 1 wants both:
- Structure. Is this an integer? Is this a well-formed email address? Is this string 200 characters or fewer?
- Business expectation. Is this quantity between 1 and 99? Is this one of the three countries we ship to? Does this discount code exist in our table?
Structure alone is not enough. -5 is a perfectly well-formed integer.
The check has to happen on a machine you control. ASVS calls this the “trusted service layer”. Anything running in the browser is a convenience for the user, not a control, because the browser belongs to whoever is sitting in front of it. The same applies to a mobile app, a desktop client, and any value your own frontend calculated and sent back to you.
The last idea has nothing to do with the shape of the data. A business logic flaw is when every individual request is valid but the sequence is wrong. Pay for one item, then edit the cart to fifty. Skip the payment step and call the confirmation endpoint directly. Nothing is malformed. The application just never checked that step three came after step two.
Prerequisites
- Ubuntu 24.04 LTS with
sudoaccess. - Node.js 22 or later, or Go 1.25 or later. Both Build a REST API with Fastify and MySQL on Ubuntu and Build a REST API in Go with Fiber v3 on Ubuntu start from installation.
curl, which Ubuntu ships by default. Every example is a small server that you poke withcurl.- Somewhere to record what you close, such as a Markdown file in your repository. Part 1 lists all 70 Level 1 requirements and explains what passing one means.
Step 1: Set Up a Scratch Project
Every control gets two tiny servers, one named -bad and one named -good. Both listen on port 3000, so run one at a time and stop it with Ctrl+C before starting the next. You will need two terminals: one for the server, one for curl.
mkdir -p ~/asvs-v2/node
cd ~/asvs-v2/node
npm init -y
npm pkg set type=module
npm install fastify
Each program is one file, started with node <name>.mjs.
mkdir -p ~/asvs-v2/go/cmd
cd ~/asvs-v2/go
go mod init asvs-v2
go get github.com/gofiber/fiber/v3 github.com/go-playground/validator/v10
Each program lives in its own folder under cmd/, started with go run ./cmd/<name>.
Step 2: Validate Input Against Rules You Define
V2.2.1 Verify that input is validated to enforce business or functional expectations for that input. This should either use positive validation against an allow list of values, patterns, and ranges, or be based on comparing the input to an expected structure and logical limits according to predefined rules. For L1, this can focus on input which is used to make specific business or security decisions. For L2 and up, this should apply to all input.
Read the last sentence again, because it decides how much work this is. At Level 1 you do not have to validate every field in the application. You have to validate the fields that make a business or security decision: quantities, prices, roles, account identifiers, status values, anything that feeds a calculation or a permission check. A free text “delivery notes” box does not make a decision, so it needs a length limit and correct encoding, not a business rule.
The endpoint below takes an order. Two fields drive money: how many, and where to. Watch what happens when nobody checks them.
Create validate-bad.mjs:
import Fastify from 'fastify'
const app = Fastify()
app.post('/orders', async (req) => {
const { quantity, country } = req.body
return { total: quantity * 9.99, country }
})
await app.listen({ port: 3000 })
Fastify has schema validation built in, so the fix is to describe what a valid order looks like and hand that description to the route. Create validate-good.mjs:
import Fastify from 'fastify'
const app = Fastify()
const orderSchema = {
type: 'object',
required: ['quantity', 'country'],
additionalProperties: false,
properties: {
quantity: { type: 'integer', minimum: 1, maximum: 99 },
country: { type: 'string', enum: ['ID', 'SG', 'MY'] }
}
}
app.post('/orders', { schema: { body: orderSchema } }, async (req) => {
const { quantity, country } = req.body
return { total: quantity * 9.99, country }
})
await app.listen({ port: 3000 })
The handler is character for character the same. The only change is the schema and the { schema: { body: orderSchema } } option in front of it.
Start each server in turn and send the same nonsense order:
$ curl -s -X POST http://localhost:3000/orders \
-H 'Content-Type: application/json' \
-d '{"quantity":-5,"country":"XX"}'
{"total":-49.95,"country":"XX"}
$ curl -s -X POST http://localhost:3000/orders \
-H 'Content-Type: application/json' \
-d '{"quantity":-5,"country":"XX"}'
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body/quantity must be >= 1"}
A total of -49.95 is not a rounding error. Depending on what the next service does with it, that is a credit.
Four things in that schema are each doing a job. type: 'integer' rejects 2.5 and the string "2". minimum and maximum are the range. enum is the allowlist of countries. required rejects a request that leaves a field out, which matters because a missing field arrives as undefined and undefined * 9.99 is NaN.
additionalProperties: false behaves differently from the rest, and it is worth knowing exactly how. Fastify does not reject unknown fields; it deletes them before your handler runs. Send {"quantity":2,"country":"ID","price":0.01} and req.body contains only quantity and country. Either way the field never reaches your code, which is the outcome you want, but do not expect a 400 when you test it.
Create cmd/validate-bad/main.go:
package main
import (
"log"
"github.com/gofiber/fiber/v3"
)
type Order struct {
Quantity int `json:"quantity"`
Country string `json:"country"`
}
func main() {
app := fiber.New()
app.Post("/orders", func(c fiber.Ctx) error {
var o Order
if err := c.Bind().Body(&o); err != nil {
return fiber.ErrBadRequest
}
return c.JSON(fiber.Map{"total": float64(o.Quantity) * 9.99, "country": o.Country})
})
log.Fatal(app.Listen(":3000"))
}
c.Bind().Body(&o) only checks that the JSON fits the struct. It has no opinion about whether -5 is a sensible quantity. The fix is go-playground/validator, which reads rules from struct tags. Create cmd/validate-good/main.go:
package main
import (
"log"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v3"
)
type Order struct {
Quantity int `json:"quantity" validate:"required,min=1,max=99"`
Country string `json:"country" validate:"required,oneof=ID SG MY"`
}
var validate = validator.New()
func main() {
app := fiber.New()
app.Post("/orders", func(c fiber.Ctx) error {
var o Order
if err := c.Bind().Body(&o); err != nil {
return fiber.ErrBadRequest
}
if err := validate.Struct(o); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"total": float64(o.Quantity) * 9.99, "country": o.Country})
})
log.Fatal(app.Listen(":3000"))
}
Two struct tags and three lines in the handler. Nothing else moved.
Start each server in turn and send the same nonsense order:
$ curl -s -X POST http://localhost:3000/orders \
-H 'Content-Type: application/json' \
-d '{"quantity":-5,"country":"XX"}'
{"country":"XX","total":-49.95}
$ curl -s -X POST http://localhost:3000/orders \
-H 'Content-Type: application/json' \
-d '{"quantity":-5,"country":"XX"}'
{"error":"Key: 'Order.Quantity' Error:Field validation for 'Quantity' failed on the 'min' tag\nKey: 'Order.Country' Error:Field validation for 'Country' failed on the 'oneof' tag"}
A total of -49.95 is not a rounding error. Depending on what the next service does with it, that is a credit.
Each tag is doing a job. min and max are the range. oneof is the allowlist of countries. required rejects a request that leaves a field out, which matters in Go because a missing number arrives as 0, and a zero quantity would otherwise sail through.
That error string is fine for learning and wrong for production. It leaks your internal struct field names to the caller. Loop over the returned validator.ValidationErrors and build your own list of field names and messages before you ship it.
One thing Go gives you for free: unknown fields in the JSON body are ignored, because they have nowhere to go in the struct. A client that sends an extra "price" field is simply talking to itself.
Step 3: Never Trust a Value the Browser Sent
V2.2.2 Verify that the application is designed to enforce input validation at a trusted service layer. While client-side validation improves usability and should be encouraged, it must not be relied upon as a security control.
Say the order form has this in it:
<input type="number" name="quantity" min="1" max="99" required>
That is good work. The browser refuses to submit an out-of-range number, the user sees the problem immediately, and nobody wastes a round trip. It is also worth exactly nothing as a security control, because the attacker never opens your form:
$ curl -s -X POST http://localhost:3000/orders \
-H 'Content-Type: application/json' \
-d '{"quantity":-5,"country":"XX"}'
Your HTML never ran. Neither did your React validation, your mobile app’s check, or the regular expression in your jQuery plugin. Keep all of them, because they make the product pleasant to use. Just never count them.
The subtler half of this requirement is about values the client calculates and sends back. It is a natural way to build a frontend: the page already knows the price, so it posts the price. Now the price is user input.
Create price-bad.mjs:
import Fastify from 'fastify'
const app = Fastify()
const catalogue = { 'tea-500g': 9.99, 'mug': 4.50 }
app.post('/cart', async (req) => {
const { sku, quantity, price } = req.body
return { sku, quantity, total: quantity * price }
})
await app.listen({ port: 3000 })
The server already has the catalogue. It just did not use it. price-good.mjs looks the price up instead of reading it:
import Fastify from 'fastify'
const app = Fastify()
const catalogue = { 'tea-500g': 9.99, 'mug': 4.50 }
app.post('/cart', async (req, reply) => {
const { sku, quantity } = req.body
const price = catalogue[sku]
if (price === undefined) return reply.code(400).send({ error: 'unknown sku' })
return { sku, quantity, total: quantity * price }
})
await app.listen({ port: 3000 })
Send the same request to each:
$ curl -s -X POST http://localhost:3000/cart \
-H 'Content-Type: application/json' \
-d '{"sku":"tea-500g","quantity":2,"price":0.01}'
{"sku":"tea-500g","quantity":2,"total":0.02}
$ curl -s -X POST http://localhost:3000/cart \
-H 'Content-Type: application/json' \
-d '{"sku":"tea-500g","quantity":2,"price":0.01}'
{"sku":"tea-500g","quantity":2,"total":19.98}
Two jars of tea for two cents, then two jars of tea for the real price. The fixed version does not validate the price at all. It never accepts one.
Create cmd/price-bad/main.go:
package main
import (
"log"
"github.com/gofiber/fiber/v3"
)
var catalogue = map[string]float64{"tea-500g": 9.99, "mug": 4.50}
type CartItem struct {
SKU string `json:"sku"`
Quantity int `json:"quantity"`
Price float64 `json:"price"`
}
func main() {
app := fiber.New()
app.Post("/cart", func(c fiber.Ctx) error {
var in CartItem
if err := c.Bind().Body(&in); err != nil {
return fiber.ErrBadRequest
}
return c.JSON(fiber.Map{"sku": in.SKU, "quantity": in.Quantity, "total": float64(in.Quantity) * in.Price})
})
log.Fatal(app.Listen(":3000"))
}
The server already has the catalogue. It just did not use it. In cmd/price-good/main.go the Price field leaves the struct entirely, which is the strongest possible form of “do not accept this from the client”:
package main
import (
"log"
"github.com/gofiber/fiber/v3"
)
var catalogue = map[string]float64{"tea-500g": 9.99, "mug": 4.50}
type CartItem struct {
SKU string `json:"sku"`
Quantity int `json:"quantity"`
}
func main() {
app := fiber.New()
app.Post("/cart", func(c fiber.Ctx) error {
var in CartItem
if err := c.Bind().Body(&in); err != nil {
return fiber.ErrBadRequest
}
price, ok := catalogue[in.SKU]
if !ok {
return c.Status(400).JSON(fiber.Map{"error": "unknown sku"})
}
return c.JSON(fiber.Map{"sku": in.SKU, "quantity": in.Quantity, "total": float64(in.Quantity) * price})
})
log.Fatal(app.Listen(":3000"))
}
Send the same request to each:
$ curl -s -X POST http://localhost:3000/cart \
-H 'Content-Type: application/json' \
-d '{"sku":"tea-500g","quantity":2,"price":0.01}'
{"quantity":2,"sku":"tea-500g","total":0.02}
$ curl -s -X POST http://localhost:3000/cart \
-H 'Content-Type: application/json' \
-d '{"sku":"tea-500g","quantity":2,"price":0.01}'
{"quantity":2,"sku":"tea-500g","total":19.98}
Two jars of tea for two cents, then two jars of tea for the real price. The fixed version does not validate the price at all. It never accepts one.
The rule generalises past prices. If the client sends it, it is input, no matter how the client got it. A role field, a userId in the body instead of from the session, a discountPercent, a isAdmin flag, an orderTotal: every one of those is a value the server can look up itself, and every one of those has been a real vulnerability in a real product.
Step 4: Enforce the Step Order of a Business Flow
V2.3.1 Verify that the application will only process business logic flows for the same user in the expected sequential step order and without skipping steps.
This is the requirement people fail without writing a single line of unsafe code. A checkout has four steps: cart, shipping, payment, confirm. Your frontend walks the user through them in order, so every request the server ever sees arrives in order, and nobody ever writes the check. Then somebody opens the network tab, copies the confirm request, and sends it on its own.
The servers below hold one order, 1001, starting in the cart stage.
Create checkout-bad.mjs:
import Fastify from 'fastify'
const app = Fastify()
const orders = new Map([['1001', { stage: 'cart' }]])
app.post('/orders/:id/pay', async (req) => {
const order = orders.get(req.params.id)
order.stage = 'paid'
return { stage: order.stage }
})
app.post('/orders/:id/confirm', async (req) => {
const order = orders.get(req.params.id)
order.stage = 'confirmed'
return { stage: order.stage, shipped: true }
})
await app.listen({ port: 3000 })
checkout-good.mjs adds three lines to one handler:
import Fastify from 'fastify'
const app = Fastify()
const orders = new Map([['1001', { stage: 'cart' }]])
app.post('/orders/:id/pay', async (req) => {
const order = orders.get(req.params.id)
order.stage = 'paid'
return { stage: order.stage }
})
app.post('/orders/:id/confirm', async (req, reply) => {
const order = orders.get(req.params.id)
if (order.stage !== 'paid') {
return reply.code(409).send({ error: 'step out of order', stage: order.stage })
}
order.stage = 'confirmed'
return { stage: order.stage, shipped: true }
})
await app.listen({ port: 3000 })
Skip the payment step against each server:
$ curl -s -X POST http://localhost:3000/orders/1001/confirm
{"stage":"confirmed","shipped":true}
$ curl -s -X POST http://localhost:3000/orders/1001/confirm
{"error":"step out of order","stage":"cart"}
Then pay first against the fixed server, and the same request works:
$ curl -s -X POST http://localhost:3000/orders/1001/pay
{"stage":"paid"}
$ curl -s -X POST http://localhost:3000/orders/1001/confirm
{"stage":"confirmed","shipped":true}
Create cmd/checkout-bad/main.go:
package main
import (
"log"
"sync"
"github.com/gofiber/fiber/v3"
)
var (
mu sync.Mutex
orders = map[string]string{"1001": "cart"}
)
func main() {
app := fiber.New()
app.Post("/orders/:id/pay", func(c fiber.Ctx) error {
mu.Lock()
defer mu.Unlock()
orders[c.Params("id")] = "paid"
return c.JSON(fiber.Map{"stage": "paid"})
})
app.Post("/orders/:id/confirm", func(c fiber.Ctx) error {
mu.Lock()
defer mu.Unlock()
orders[c.Params("id")] = "confirmed"
return c.JSON(fiber.Map{"stage": "confirmed", "shipped": true})
})
log.Fatal(app.Listen(":3000"))
}
cmd/checkout-good/main.go adds three lines to one handler:
package main
import (
"log"
"sync"
"github.com/gofiber/fiber/v3"
)
var (
mu sync.Mutex
orders = map[string]string{"1001": "cart"}
)
func main() {
app := fiber.New()
app.Post("/orders/:id/pay", func(c fiber.Ctx) error {
mu.Lock()
defer mu.Unlock()
orders[c.Params("id")] = "paid"
return c.JSON(fiber.Map{"stage": "paid"})
})
app.Post("/orders/:id/confirm", func(c fiber.Ctx) error {
mu.Lock()
defer mu.Unlock()
if orders[c.Params("id")] != "paid" {
return c.Status(409).JSON(fiber.Map{"error": "step out of order", "stage": orders[c.Params("id")]})
}
orders[c.Params("id")] = "confirmed"
return c.JSON(fiber.Map{"stage": "confirmed", "shipped": true})
})
log.Fatal(app.Listen(":3000"))
}
Skip the payment step against each server:
$ curl -s -X POST http://localhost:3000/orders/1001/confirm
{"shipped":true,"stage":"confirmed"}
$ curl -s -X POST http://localhost:3000/orders/1001/confirm
{"error":"step out of order","stage":"cart"}
Then pay first against the fixed server, and the same request works:
$ curl -s -X POST http://localhost:3000/orders/1001/pay
{"stage":"paid"}
$ curl -s -X POST http://localhost:3000/orders/1001/confirm
{"shipped":true,"stage":"confirmed"}
The mutex is there because these demos keep the stage in a map in memory and Fiber handles requests concurrently. In a real application the stage is a column on the order row, and the database does that job.
Three details separate the demo from a real implementation.
The stage belongs in your database, not in the session. A session lives in the browser’s cookie jar and the user can throw it away. The state of order 1001 is a fact about the order, so it goes in the order row, and every step reads it from there.
“For the same user” is in the requirement text for a reason. Checking that order 1001 is paid is only half the job. You also have to check that order 1001 belongs to the person asking. That second half is chapter V8 Authorization, which is Part 10, and the two failures usually ship together.
Move the stage and check it in one operation. Two identical confirm requests arriving at the same moment can both read paid before either writes confirmed. Use a conditional update (UPDATE orders SET stage = 'confirmed' WHERE id = $1 AND stage = 'paid') and act on how many rows it changed. That closes the race and passes the requirement at the same time.
Step 5: Write the Validation Rules Down
V2.1.1 Verify that the application’s documentation defines input validation rules for how to check the validity of data items against an expected structure. This could be common data formats such as credit card numbers, email addresses, telephone numbers, or it could be an internal data format.
This is the first of four documentation requirements at Level 1, and it is the one people skip because it produces no code. It is also the one that makes the other three requirements in this chapter verifiable. “Is this input validated correctly?” has no answer until someone has written down what correct means.
Keep it in the repository next to the code, so it goes through review with the change that alters it. Create docs/input-validation.md:
# Input Validation Rules
Version 1.0. Covers every field that makes a business or security decision,
per ASVS 5.0 V2.2.1. Enforced by the schema in `src/schemas/`; the browser
form is a convenience only.
## Field rules
| Field | Type | Rule | Rejected example |
|---|---|---|---|
| `quantity` | integer | 1 to 99 inclusive | `-5`, `2.5`, `"2"` |
| `country` | string | one of `ID`, `SG`, `MY` | `XX`, `id` |
| `sku` | string | must exist in the product catalogue | `tea-500g-free` |
| `email` | string | RFC 5322 address, 254 chars max, one `@` | `a@[email protected]` |
| `phone` | string | E.164, `+` then 8 to 15 digits | `08123456789` |
| `discountCode` | string | `^[A-Z0-9]{6,12}$` and exists in `discounts` | `SUMMER!` |
## Rules that are not about one field
- `price` and `total` are never read from the request. The server reads
them from the catalogue.
- Unknown fields in a request body are dropped, not passed through.
- Order stages run `cart` then `shipping` then `payment` then `confirmed`.
No step may be skipped.
## Where each rule is enforced
Every rule above is enforced in the API layer, before any handler runs.
No rule is enforced only in the browser.
Six field rules and three sentences of prose is a passing document. It does not have to be long. It has to exist, be accurate, and say where the rule is enforced, because that last column is what an auditor or a new teammate actually checks.
Keep it honest as the code changes. A validation document that says quantity maxes out at 99 while the schema says 999 is worse than no document, because the next person will trust it.
Common Mistakes and Troubleshooting
Validating with a denylist because it was quicker. Rejecting XX as a country closes one bad value out of thousands. Listing the three countries you ship to closes all of them, and it stays correct when someone adds a fourth, because they have to edit the list.
Validating everything, then giving up. Level 1 asks for the fields that make a business or security decision. Trying to write rules for all 400 fields in your application at once is how this requirement ends up half done. Start with money, quantities, identifiers, roles, and status values.
Trusting a value because your own frontend sent it. “Our app calculates that” is not a control. So does anyone with the developer tools open. If the server can look the value up, it must look the value up.
Confusing input validation with output encoding. Both are needed and neither substitutes for the other. Validation stopped the order for minus five items; only encoding stops the name that ends up as a script tag on your page. Part 2 covers the second half.
Assuming the requests always arrive in the order your UI sends them. Nothing enforces that except your server. Test your flow by calling the last endpoint first, on purpose, and see what happens.
Checking the stage but not the owner. If a valid confirm on order 1001 works for any signed-in user, you have passed V2.3.1 and failed authorization. Check both in the same query.
Best Practices
Declare the rules once, in one place, and let the framework enforce them. A Fastify schema on the route or a struct tag on the request type is checked before your handler runs, which means there is no path where someone forgot. In Go you can go further and pass a StructValidator in fiber.Config, so c.Bind().Body() validates on its own and an untagged struct is the only way to skip it.
Make the request type unable to hold a value you refuse to accept. Deleting the Price field is stronger than validating it, because there is no code path left that reads it. That principle also covers role, userId, and every other field the server should look up for itself.
Turn every rejected example in your document into a test. -5, 2.5, "2", XX, an unknown SKU, a confirm before a payment: that is six assertions and about twenty lines. They will fail loudly the day somebody relaxes a schema to fix a bug in a hurry.
Set a maximum length on every string, including the ones with no business rule. A delivery notes field with no limit is a memory and storage problem long before it is a security one. maxLength in a schema and max=2000 in a struct tag are one word each.
Validate at the edge, then trust your own types. Once a request has been turned into a validated Order, the code behind it should not re-check the quantity. Re-checking everywhere makes it unclear who owns the rule, and the copies drift apart.
Conclusion
You have closed all four Level 1 requirements in V2. Input is now checked against rules you chose rather than accepted as sent, the checks run on the server where the user cannot reach them, values like price are looked up rather than received, the checkout refuses to confirm an order nobody paid for, and every rule is written down in a file that ships with the code.
Look back at the fixed versions and notice how small they are. A schema object, two struct tags, one map lookup, one if. These are not hard controls to implement. They are easy controls to forget, which is why a standard names them.
Mark V2.1.1, V2.2.1, V2.2.2, and V2.3.1 as passed in your own record, and note the file and line where each check lives.
The next part covers V3 Web Frontend Security and its eight Level 1 requirements: cookie attributes and prefixes, HSTS, CORS, cross-site request forgery, and stopping the browser from rendering a response in a context you never intended.
The OWASP Application Security Verification Standard is licensed under Creative Commons Attribution-ShareAlike 4.0, which is what permits the requirement text to be reproduced here.