This is the shortest part of the series. ASVS chapter V4 API and Web Service has four sections, but only two of its requirements apply at Level 1: send a Content-Type header that actually describes what you sent, and never run a WebSocket over plain ws://.
Both look like housekeeping. Neither is. A JSON response labelled text/html is a cross-site scripting hole that no amount of output encoding will close, because the browser was told to treat your data as a document. A WebSocket on ws:// puts every message, including the session token in the first frame, on the wire in plain text where anyone on the same network can read it.
Everything below exists in both Node.js with Fastify and Go with Fiber. Pick your stack once and the whole article follows it. Each control is a broken version next to a fixed version, one line apart.
Conceptual Overview
Content-Type is a promise about the bytes that follow. It has two parts: the media type (application/json, text/html, image/png) and, for text formats, the character set (charset=utf-8). The media type tells the browser what parser to use. The character set tells it how to turn bytes into characters. Get the first one wrong and the browser runs your data as code. Get the second one wrong and it decodes your text with the wrong table.
If you do not declare a character set, the browser guesses. For text/html with no charset, browsers fall back to a legacy single-byte encoding. Your UTF-8 bytes get decoded one byte at a time, so é becomes é. That is the visible symptom. The security problem underneath is that when the browser is guessing, an attacker who controls part of the response can influence the guess, and a byte sequence that looks harmless in UTF-8 can become a < in another encoding.
WebSockets are not HTTP requests once they are open. A WebSocket starts as an HTTP request with an Upgrade header, and after the server answers 101 Switching Protocols, the connection stops being HTTP. Nothing you know about HTTP applies to it any more: no CORS, no preflight, no per-request headers, no Content-Type. The browser will not stop a page from opening a WebSocket to any host it likes.
Masking is not encryption. The WebSocket protocol requires clients to XOR every frame they send with a random key, and it sends that key in the same frame. It exists to stop confused proxies misreading traffic, not to hide anything. Server-to-client frames are not masked at all. You will see exactly that in Step 4.
Prerequisites
- Ubuntu 24.04 LTS with
sudoaccess. - Node.js 22 or later, or Go 1.25 or later, from Build a REST API with Fastify and MySQL on Ubuntu or Build a REST API in Go with Fiber v3 on Ubuntu.
curl,openssl, andtcpdump. Install the last one withsudo apt install tcpdump.- A browser, for the two steps where the difference only shows up in one.
- Somewhere to record what you close. Part 1 lists all 70 Level 1 requirements.
Step 1: Set Up a Scratch Project
Every control is a pair of servers named -bad and -good, both on port 3000. Run one at a time and use Ctrl+C between them.
mkdir -p ~/asvs-v4/node
cd ~/asvs-v4/node
npm init -y
npm pkg set type=module
npm install fastify ws
Each program is one file, started with node <name>.mjs.
mkdir -p ~/asvs-v4/go/cmd
cd ~/asvs-v4/go
go mod init asvs-v4
go get github.com/gofiber/fiber/v3 github.com/coder/websocket
Each program lives in its own folder under cmd/, started with go run ./cmd/<name>.
Step 2: Send a Content-Type That Matches the Body
V4.1.1 Verify that every HTTP response with a message body contains a Content-Type header field that matches the actual content of the response, including the charset parameter to specify safe character encoding (e.g., UTF-8, ISO-8859-1) according to IANA Media Types, such as “text/”, “/+xml” and “/xml”.
The mismatch that hurts most is JSON served as HTML. It happens by accident: someone sets a default Content-Type in middleware, or copies a route that used to render a template, or reaches for send on a framework where the default is text/html. The endpoint keeps returning correct JSON, every test passes, and the browser now parses your user’s search term as markup.
Create ct-bad.mjs:
import Fastify from 'fastify'
const app = Fastify()
app.get('/search', async (req, reply) => {
const results = { query: req.query.q, hits: 0 }
return reply.type('text/html').send(JSON.stringify(results))
})
await app.listen({ port: 3000 })
ct-good.mjs changes the string:
import Fastify from 'fastify'
const app = Fastify()
app.get('/search', async (req, reply) => {
const results = { query: req.query.q, hits: 0 }
return reply.type('application/json; charset=utf-8').send(JSON.stringify(results))
})
await app.listen({ port: 3000 })
$ curl -s -i 'http://localhost:3000/search?q=hello' | head -3
HTTP/1.1 200 OK
content-type: text/html
content-length: 26
$ curl -s -i 'http://localhost:3000/search?q=hello' | head -3
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 26
Create cmd/ct-bad/main.go:
package main
import (
"encoding/json"
"log"
"github.com/gofiber/fiber/v3"
)
type Results struct {
Query string `json:"query"`
Hits int `json:"hits"`
}
func main() {
app := fiber.New()
app.Get("/search", func(c fiber.Ctx) error {
body, _ := json.Marshal(Results{Query: c.Query("q")})
c.Set("Content-Type", "text/html")
return c.Send(body)
})
log.Fatal(app.Listen(":3000"))
}
cmd/ct-good/main.go changes the string:
package main
import (
"encoding/json"
"log"
"github.com/gofiber/fiber/v3"
)
type Results struct {
Query string `json:"query"`
Hits int `json:"hits"`
}
func main() {
app := fiber.New()
app.Get("/search", func(c fiber.Ctx) error {
body, _ := json.Marshal(Results{Query: c.Query("q")})
c.Set("Content-Type", "application/json; charset=utf-8")
return c.Send(body)
})
log.Fatal(app.Listen(":3000"))
}
$ curl -s -i 'http://localhost:3000/search?q=hello' | head -4
HTTP/1.1 200 OK
Date: Thu, 20 Aug 2026 09:14:02 GMT
Content-Type: text/html
Content-Length: 26
$ curl -s -i 'http://localhost:3000/search?q=hello' | head -4
HTTP/1.1 200 OK
Date: Thu, 20 Aug 2026 09:14:19 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 26
curl shows the header and nothing else, because curl does not care. The difference only appears in a browser. Start the broken server and open this URL:
http://localhost:3000/search?q=<img src=x onerror=alert(1)>
An alert box appears. Your JSON was never wrong: the response body is still {"query":"<img src=x onerror=alert(1)>","hits":0}, exactly as curl shows it. The browser was told it was HTML, so it built a page from it, found an <img> tag with a broken source, and ran the error handler. Open the same URL against the fixed server and the browser displays the JSON as text.
Two related habits are worth picking up here. Use your framework’s JSON helper (reply.send(object) in Fastify, c.JSON(...) in Fiber) rather than serialising by hand, because it sets the media type for you. And send X-Content-Type-Options: nosniff on every response, which stops the browser overriding a Content-Type it thinks is wrong. That header was Part 4, Step 2.
Step 3: Declare the Character Set
The second half of V4.1.1 is the charset parameter, and it is easy to lose even when the media type is right. The requirement calls it out explicitly for text formats.
Create charset-bad.mjs:
import Fastify from 'fastify'
const app = Fastify()
app.get('/profile', async (req, reply) => {
return reply.type('text/html').send('<p>Halo, Zoë Kertész</p>')
})
await app.listen({ port: 3000 })
charset-good.mjs adds five characters:
import Fastify from 'fastify'
const app = Fastify()
app.get('/profile', async (req, reply) => {
return reply.type('text/html; charset=utf-8').send('<p>Halo, Zoë Kertész</p>')
})
await app.listen({ port: 3000 })
Create cmd/charset-bad/main.go:
package main
import (
"log"
"github.com/gofiber/fiber/v3"
)
func main() {
app := fiber.New()
app.Get("/profile", func(c fiber.Ctx) error {
c.Set("Content-Type", "text/html")
return c.SendString("<p>Halo, Zoë Kertész</p>")
})
log.Fatal(app.Listen(":3000"))
}
cmd/charset-good/main.go adds five characters:
package main
import (
"log"
"github.com/gofiber/fiber/v3"
)
func main() {
app := fiber.New()
app.Get("/profile", func(c fiber.Ctx) error {
c.Set("Content-Type", "text/html; charset=utf-8")
return c.SendString("<p>Halo, Zoë Kertész</p>")
})
log.Fatal(app.Listen(":3000"))
}
Open http://localhost:3000/profile against each server. The broken one prints:
Halo, Zoë Kertész
The fixed one prints:
Halo, Zoë Kertész
Same bytes on the wire both times. The only difference is that the second response told the browser how to read them. Most teams meet this bug as a display complaint from a user with an accent in their name, fix it with a <meta charset> tag in the template, and never notice that the API responses are still undeclared. The header is the authoritative answer, and it is the one this requirement asks for.
Step 4: Serve WebSockets Over TLS
V4.4.1 Verify that WebSocket over TLS (WSS) is used for all WebSocket connections.
A one-sentence requirement with a one-line fix, and a demonstration worth doing once so you never forget it.
Generate a self-signed certificate first. Browsers will warn about it, which is correct, but it is enough to see the difference:
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout key.pem -out cert.pem -days 365 -subj '/CN=localhost'
Create ws-bad.mjs, an echo server on plain ws://:
import { WebSocketServer } from 'ws'
const wss = new WebSocketServer({ port: 3000 })
wss.on('connection', (socket) => {
socket.on('message', (data) => socket.send(`echo: ${data}`))
})
ws-good.mjs puts the same server behind an HTTPS listener, which is all wss:// means:
import { createServer } from 'node:https'
import { readFileSync } from 'node:fs'
import { WebSocketServer } from 'ws'
const server = createServer({
cert: readFileSync('cert.pem'),
key: readFileSync('key.pem')
})
const wss = new WebSocketServer({ server })
wss.on('connection', (socket) => {
socket.on('message', (data) => socket.send(`echo: ${data}`))
})
server.listen(3000)
One client works against both. Save it as ws-client.mjs:
import WebSocket from 'ws'
const socket = new WebSocket(process.argv[2], { rejectUnauthorized: false })
socket.on('open', () => socket.send('token=8f14e45fceea167a'))
socket.on('message', (data) => {
console.log(String(data))
socket.close()
})
rejectUnauthorized: false is only there because the certificate is self-signed. Never ship that.
Now watch the wire. Start ws-bad.mjs, and in a second terminal run:
sudo tcpdump -i lo -A -n 'tcp port 3000' | grep -a 'token='
In a third terminal, run the client:
$ node ws-client.mjs ws://localhost:3000
echo: token=8f14e45fceea167a
The tcpdump terminal prints the message straight out of the packet:
.38.......echo: token=8f14e45fceea167a
Stop everything, start ws-good.mjs, and repeat with wss://:
$ node ws-client.mjs wss://localhost:3000
echo: token=8f14e45fceea167a
The client behaves identically. The tcpdump terminal prints nothing at all.
One more check, to see what a client using the old URL gets:
$ node ws-client.mjs ws://localhost:3000
Error: socket hang up
That failure is the point. Once the server speaks only wss://, there is no quiet fallback to plain text.
Fiber v3 does not ship a WebSocket middleware yet, so these two use the standard library with github.com/coder/websocket. Create cmd/ws-bad/main.go:
package main
import (
"context"
"log"
"net/http"
"github.com/coder/websocket"
)
func echo(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer c.CloseNow()
_, data, err := c.Read(context.Background())
if err != nil {
return
}
c.Write(context.Background(), websocket.MessageText, append([]byte("echo: "), data...))
}
func main() {
http.HandleFunc("/", echo)
log.Fatal(http.ListenAndServe(":3000", nil))
}
cmd/ws-good/main.go changes the last line, which is all wss:// means:
package main
import (
"context"
"log"
"net/http"
"github.com/coder/websocket"
)
func echo(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer c.CloseNow()
_, data, err := c.Read(context.Background())
if err != nil {
return
}
c.Write(context.Background(), websocket.MessageText, append([]byte("echo: "), data...))
}
func main() {
http.HandleFunc("/", echo)
log.Fatal(http.ListenAndServeTLS(":3000", "cert.pem", "key.pem", nil))
}
One client works against both. Save it as cmd/ws-client/main.go:
package main
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"os"
"github.com/coder/websocket"
)
func main() {
client := &http.Client{Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}}
c, _, err := websocket.Dial(context.Background(), os.Args[1], &websocket.DialOptions{HTTPClient: client})
if err != nil {
fmt.Println("dial error:", err)
return
}
defer c.CloseNow()
c.Write(context.Background(), websocket.MessageText, []byte("token=8f14e45fceea167a"))
_, data, err := c.Read(context.Background())
if err != nil {
fmt.Println("read error:", err)
return
}
fmt.Println(string(data))
}
InsecureSkipVerify: true is only there because the certificate is self-signed. Never ship that.
Now watch the wire. Start go run ./cmd/ws-bad, and in a second terminal run:
sudo tcpdump -i lo -A -n 'tcp port 3000' | grep -a 'token='
In a third terminal, run the client:
$ go run ./cmd/ws-client ws://localhost:3000
echo: token=8f14e45fceea167a
The tcpdump terminal prints the message straight out of the packet:
WZUg..#...echo: token=8f14e45fceea167a
Stop everything, start ws-good, and repeat with wss://:
$ go run ./cmd/ws-client wss://localhost:3000
echo: token=8f14e45fceea167a
The client behaves identically. The tcpdump terminal prints nothing at all.
One more check, to see what a client using the old URL gets:
$ go run ./cmd/ws-client ws://localhost:3000
dial error: failed to WebSocket dial: expected handshake response status code 101 but got 400
That failure is the point. Once the server speaks only wss://, there is no quiet fallback to plain text.
Look closely at what tcpdump caught: the server’s reply, not the client’s message. That is the masking rule from the overview. The client’s frame was XOR’d with a random key, and the key travelled in the same frame, so anyone capturing the traffic can undo it in one line of code. Only TLS actually hides anything.
Three notes for real deployments. In most setups the certificate lives on the reverse proxy, not in your application, and Nginx upgrades the connection with proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade". Browsers refuse a ws:// connection opened from an https:// page as mixed content, which catches some of this for you but does nothing for mobile or server-side clients. And because the WebSocket handshake carries cookies but gets no CORS protection, check the Origin header in your Accept call: websocket.Accept takes an OriginPatterns option for exactly that, and the ws library gives you the handshake request to inspect.
If your application has no WebSockets at all, V4.4.1 is genuinely not applicable. Write that in your record along with the reason, as Part 1 describes. “Not applicable” with an explanation is a passing answer. “Not applicable” on its own is a gap.
Common Mistakes and Troubleshooting
Setting a global default Content-Type in middleware. It is right for most routes and silently wrong for the file download, the CSV export, and the health check. Set the type where the body is produced.
Trusting curl to tell you the response is safe. curl prints bytes and ignores the media type. Every mismatch in this article is invisible from the terminal and obvious in a browser.
Adding <meta charset="utf-8"> and calling the charset job done. The meta tag only works for HTML documents the browser parses, and the header wins when both are present. JSON, CSV, and plain text responses have no meta tag at all.
Assuming wss:// is on because production is on HTTPS. The scheme in the client’s URL is a separate string, often built from a config value or hardcoded in a mobile build. Grep for ws:// across every client you ship.
Thinking WebSocket masking provides privacy. It is an XOR with a key sent alongside the data. It provides none.
Best Practices
Let the framework set the media type. reply.send(object) and c.JSON(...) know what they produced. Hand-written headers drift from the body the moment a route changes shape.
Add charset=utf-8 to every text response, including error pages. Error handlers are where undeclared types collect, because nobody writes a test for the 500 page.
Pair the type with nosniff. A correct Content-Type tells the browser what to do; X-Content-Type-Options: nosniff stops it deciding otherwise.
Test the header, not just the body. One assertion per endpoint that the Content-Type starts with application/json will catch a whole class of regressions, and it costs a line.
Terminate WebSocket TLS where you terminate HTTP TLS. Two places to renew a certificate is one place too many, and a proxy that already handles HSTS and HTTP/2 handles the upgrade fine.
Conclusion
You have closed both Level 1 requirements in V4. Responses now declare what they are and how they are encoded, and WebSocket traffic is encrypted instead of readable by anyone on the path.
The whole chapter came down to one header and one letter. The tcpdump capture in Step 4 is the part worth keeping: run it once against your own staging environment and you will never argue about ws:// again.
Mark V4.1.1 and V4.4.1 as passed in your own record, or mark V4.4.1 not applicable with a note saying your application opens no WebSockets.
The next part covers V5 File Handling and its four Level 1 requirements: what happens between a user picking a file and your server storing it, and every way that goes wrong.
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.