NHTSA vPIC endpoints are public HTTP APIs. It is natural to ask: why not fetch DecodeVinValues straight from the browser? Sometimes that works in demos. In a real free VIN product, you usually want a thin backend proxy. This post explains when the browser call is fine, when it breaks, and a minimal TypeScript proxy shape that keeps secrets, quotas, and CORS under your control.
What goes wrong with browser-direct vPIC
-
CORS and preflight -- browser calls are subject to CORS. If the upstream response does not allow your origin (or changes policy), your SPA breaks while
curlfrom a server still works. You do not control NHTSA's Access-Control headers. - No server-side cache -- every tab hits the public API. You cannot share a negative cache, ETag layer, or single-flight map across users.
- Quota and fairness -- rate limits and courtesy caps need a central queue. The browser cannot enforce global concurrency across visitors.
- Logging and abuse -- scrapers will point at whatever URL your JS reveals. A proxy lets you attach auth cookies, bot checks, and per-IP limits before traffic reaches vPIC.
- API shape churn -- you may want to normalize field names, strip empty sentinels, or add your own error codes. Doing that only in the client duplicates logic across web and mobile.
Direct browser calls are reasonable for a personal script, a localhost experiment, or a one-off admin tool behind VPN. They are a weak foundation for a public decode site.
When a proxy is the right default
Proxy when any of these are true:
- You show decode results to anonymous internet users
- You need caching, single-flight, or fair queues
- You want stable error codes (
VIN_INVALID,UPSTREAM_TIMEOUT,QUEUE_FULL) - You plan mobile apps that should share one contract
- You must avoid baking upstream URLs and query conventions into every client release
A thin proxy, not a second database
Keep the proxy boring: validate VIN, call vPIC, map the response, return JSON. Do not scrape HTML. Do not invent plant or trim fields the upstream omitted.
import { createServer } from "node:http";
const VPIC =
"https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues";
const VIN_RE = /^[A-HJ-NPR-Z0-9]{17}$/;
async function decodeViaProxy(vin: string): Promise<unknown> {
if (!VIN_RE.test(vin)) {
const err = new Error("VIN_INVALID");
(err as Error & { status: number }).status = 400;
throw err;
}
const url = `${VPIC}/${encodeURIComponent(vin)}?format=json`;
const res = await fetch(url, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(8_000),
});
if (!res.ok) {
const err = new Error("UPSTREAM_HTTP");
(err as Error & { status: number }).status = 502;
throw err;
}
return res.json();
}
createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json; charset=utf-8");
// Set CORS for YOUR frontend origins only -- not *
res.setHeader("Access-Control-Allow-Origin", "https://www.example.com");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
try {
const u = new URL(req.url ?? "/", "http://localhost");
if (u.pathname !== "/api/decode") {
res.writeHead(404);
res.end(JSON.stringify({ error: "NOT_FOUND" }));
return;
}
const vin = (u.searchParams.get("vin") ?? "").trim().toUpperCase();
const body = await decodeViaProxy(vin);
res.writeHead(200);
res.end(JSON.stringify(body));
} catch (e) {
const status = (e as { status?: number }).status ?? 500;
const message = e instanceof Error ? e.message : "ERROR";
res.writeHead(status);
res.end(JSON.stringify({ error: message }));
}
}).listen(3080);
Production should add the fair queue, cache, and structured logging discussed elsewhere. The point of this skeleton is control: your origin, your timeouts, your error vocabulary.
CORS on your proxy, not on NHTSA
You own Access-Control-Allow-Origin for /api/decode. Allow only the origins you ship (marketing site, app subdomain). Avoid * if responses might ever include user-specific headers or cookies. Prefer same-site deployment (frontend and API under one site) so many browsers need no CORS dance at all.
Security and privacy notes
- Do not log full VINs at info level in shared multi-tenant hosts; hash or truncate for analytics
- Cap request body and query size; VINs are 17 characters after normalization
- Time out upstream calls; a hung vPIC socket should not exhaust your worker pool
- Return 400 for charset/length failures before any upstream call
Hybrid approaches
Some teams call vPIC from the server for anonymous web traffic and allow a signed internal tool to hit a second route with higher limits. That is still a proxy. The browser never holds the "privileged" path. Edge workers can host the proxy close to users as long as you keep one shared cache/queue story -- otherwise each edge POP becomes its own burst source toward NHTSA.
Takeaway
Calling NHTSA vPIC from the browser is fine for experiments and brittle for public products. Proxy DecodeVinValues through your backend when you need CORS you control, shared caching, fair queues, stable errors, and abuse brakes. Keep the proxy thin: validate, fetch, normalize, respond -- and leave factory folklore out of the mapping layer.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)