Quick answer: Before a JSON API redirect reaches production, test whether the next hop keeps or changes the method, whether the body can be replayed, which credentials cross the boundary, how every
Locationvalue resolves, and whether chains terminate at the expected JSON endpoint. A successful final status alone can hide a dangerous intermediate request.
Redirects are often introduced outside application code by a gateway, trailing-slash rule, hostname migration, or HTTP-to-HTTPS upgrade. That makes them easy to miss in unit tests. The five checks below treat every hop as part of the API contract and use small fixtures that can run against the deployed route.
1. Does each redirect status produce the intended method and body?
Send the same JSON write through 301, 302, 303, 307, and 308 fixtures, then record the method and body received at the destination. A 303 deliberately becomes GET, while 307 and 308 preserve both method and body. Historical 301 and 302 behavior varies enough that non-GET APIs should not depend on it.
The current MDN redirect guide separates the ambiguous historic statuses from the explicit ones. A 303 See Other tells the client to retrieve the target using GET. A 307 Temporary Redirect and 308 Permanent Redirect retain the original method and body. RFC 9110 is the primary HTTP semantics specification.
Build a destination that echoes what it actually received:
import http from "node:http";
const server = http.createServer(async (req, res) => {
if (req.url.startsWith("/move/")) {
const status = Number(req.url.split("/").pop());
res.writeHead(status, { Location: "/inspect" }).end();
return;
}
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
method: req.method,
body: Buffer.concat(chunks).toString("utf8")
}));
});
Drive it with the production client, not only a generic HTTP tester:
const response = await fetch(`${base}/move/307`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderId: "ord-42" })
});
const received = await response.json();
console.assert(received.method === "POST");
console.assert(received.body === '{"orderId":"ord-42"}');
Repeat the assertion table for every status your infrastructure can emit. A GET-only smoke test cannot prove write behavior, and checking only the final 200 hides whether a POST was rewritten on the way.
| Redirect | Expected follow-up for a POST | Suitable API intent |
|---|---|---|
| 301 | Client behavior may rewrite to GET | Permanent move where non-GET compatibility is not assumed |
| 302 | Client behavior may rewrite to GET | Temporary move where non-GET compatibility is not assumed |
| 303 | GET without the original body | Retrieve a result after processing a write |
| 307 | Same method and body | Temporary method-preserving move |
| 308 | Same method and body | Permanent method-preserving move |
2. Can the redirected request safely replay its JSON body?
Verify that a preserved body is replayable, unchanged, and safe to execute twice if transport failure obscures the result. Buffered JSON may replay cleanly, while a one-shot stream can fail during a 307 or 308 follow-up. A method-preserving redirect does not make a non-idempotent operation safe from duplicate processing.
Two separate problems live here. First, the client needs another readable copy of the body. Browser fetch() can replay a body it owns, but server libraries may reject or require special handling for non-rewindable streams. Test the actual upload path, including payload sizes that trigger streaming rather than buffering.
Second, the destination might receive the same logical write more than once. A client can send the request, lose the response, and retry at either the original or redirected URL. Redirect support therefore belongs beside an idempotency policy, not in place of one. The JSON API idempotency mistakes guide covers duplicate-order prevention with server-side keys and stored outcomes.
Use an operation ID in the test payload and count durable effects:
{
"operationId": "redirect-test-001",
"orderId": "ord-42",
"action": "confirm"
}
Run a successful redirect, a connection interruption after the destination commits, and a retry. The acceptance test should define whether the second attempt returns the stored outcome, reports a documented conflict, or creates another effect. “The body arrived intact” is necessary but insufficient.
Also verify Content-Type and Content-Length or transfer framing at the second hop. A JSON body copied without its media type can reach the route but bypass the JSON parser. If a proxy decompresses or transforms a body, compare the application-visible bytes rather than assuming the wire representation stays identical.
3. Which credentials and sensitive fields reach the redirect target?
Redirect a credentialed request to the same origin, another subdomain, another port, and an unrelated origin under test control. Record Authorization, cookies, custom API-key headers, tenant headers, and the JSON body at every destination. Do not assume that stripping one standard header prevents other secrets or payload data from crossing origins.
The Fetch Standard defines detailed redirect processing, including removing certain credential headers when origin changes. Real client libraries add their own policies and have had security defects. A 2026 GitHub-reviewed advisory documented cross-domain forwarding of custom authentication headers in follow-redirects, even though standard authorization and cookie headers were stripped. That is evidence to test dependencies, not a claim that every client behaves the same.
Design the assertion as an allowlist:
const permittedAtCrossOrigin = new Set([
"accept",
"content-type",
"user-agent"
]);
for (const name of capturedHeaderNames) {
if (!permittedAtCrossOrigin.has(name)) {
throw new Error(`unexpected redirected header: ${name}`);
}
}
The exact safe set depends on the operation. A 307 or 308 can preserve the JSON body, which may contain personal data or credentials even when headers are cleaned. If a sensitive API should never change origin, configure the client to reject redirects and treat any 3xx response as a routing failure.
In browser code, cross-origin redirect behavior also meets CORS, cookie SameSite rules, and response filtering. In backend code, there is no browser CORS enforcement. Run both paths when both exist. The JSON API CORS checklist explains why a command-line success cannot establish browser behavior.
4. Does every Location value resolve to the intended trusted URL?
Test absolute URLs, root-relative paths, path-relative references, query strings, encoded segments, alternate ports, and scheme changes. Resolve Location against the current request URL at every hop, then enforce the destination policy on the result. String-prefix checks are not sufficient protection against confusing hosts, credentials, encodings, or path normalization.
The Location field can contain a relative reference. MDN's Location header reference explains which redirect statuses use the initiating method and which switch to GET. For URL background, Wikipedia's URL redirection article also notes that unvalidated redirect targets can become open-redirect vulnerabilities; RFC 9110 remains the authority for HTTP behavior.
Use the platform URL parser rather than concatenating strings:
const current = new URL("https://api.example.com/v1/orders/42");
const next = new URL("../orders/43?view=compact", current);
console.assert(next.href ===
"https://api.example.com/v1/orders/43?view=compact");
console.assert(next.protocol === "https:");
console.assert(next.hostname === "api.example.com");
Test rejection too: an HTTP downgrade, unexpected port, non-HTTP scheme, user-info section, or destination outside the allowlist should stop before the client sends a second request. Reapply the policy after every hop because a trusted first destination can redirect again.
Keep fragments out of server-side assumptions. URL fragments are handled by clients and are not sent in the HTTP request target. Preserve query parameters only when the redirect contract requires them; blindly carrying a signed query to a different destination can disclose it or invalidate the signature.
5. Does the chain terminate at the expected JSON response?
Set a small redirect limit and test a self-loop, a two-URL loop, an overlong chain, a missing Location field, and a final HTML response. On success, assert the final URL, status, Content-Type, and JSON shape. A client reporting 200 or response.redirected alone does not prove that it reached the intended API.
Automatic following makes a broken chain look deceptively healthy. In standards-based fetch, response.redirected shows that at least one redirect happened and response.url identifies the final URL. It does not expose a complete portable hop history. When redirects are unexpected, the Fetch Standard recommends filtering them through the API by using redirect: "error" rather than discovering them only after a possibly unsafe request.
const response = await fetch(apiUrl, { redirect: "error" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const type = response.headers.get("content-type") || "";
if (!type.toLowerCase().includes("application/json")) {
throw new Error(`expected JSON, received ${type || "no type"}`);
}
const data = await response.json();
For routes where redirects are intentional, capture hops in a controlled integration proxy or configure the client for manual handling where the runtime makes those responses visible. Store only sanitized traces: status, method, origin, path pattern, and selected safe headers. Do not log bearer tokens or full personal payloads.
The final JSON still needs validation. Use the JSON Viewer to inspect a non-sensitive captured body, but do not confuse a well-formed document with a correct redirect chain. If the final page begins with HTML, follow the unexpected-token guide to distinguish a login page, proxy error, and wrong route.
What should a JSON API redirect test report?
Report every hop's status, resolved URL, outgoing method, body presence, safe header names, and timing, followed by the final status, media type, and payload assertion. Record the client and version under test. The same redirect can behave differently across browser fetch, server runtimes, curl, mobile SDKs, proxies, and cached permanent routes.
A release matrix should include:
- every 3xx status the gateway or framework can return;
- GET plus each write method used by the API;
- buffered and streamed request bodies where supported;
- same-origin and controlled cross-origin destinations;
- relative, absolute, invalid, and disallowed
Locationvalues; - loops, excessive hops, missing headers, and non-JSON final bodies.
Keep the fixture deterministic. Use local test servers or reserved internal hosts, never an arbitrary public redirect service with real credentials. Assert destination-side observations rather than only client-side promises. The most valuable failure message says “307 replayed POST body to an unapproved origin,” not merely “redirect test failed.”
These five tests cover redirect mechanics, not whether a redirect is the best API design. Stable machine endpoints are easier to secure and operate. When a move is unavoidable, publish the target, lifetime, credential policy, and client expectations, then keep the old-path test until supported clients have migrated.
Run the matrix again when a CDN, ingress controller, authentication layer, framework router, or client dependency changes. Any one of those components can introduce a hop or alter redirect handling without changing the endpoint's application code. Production-like integration tests are especially important for trailing-slash normalization and hostname upgrades because local development often bypasses the proxy that creates them.