VINQUERY / DEVELOPER GUIDANCE

Server-side proxy protection

Implementation guidance, not a description of controls deployed on every VINquery demo. Code integration requirements and validation limits are included in the download.

Protecting server-side proxies from abuse

A practical guide for applications using VINdecode, VINfix, VINocr, VINbarcode, and LPR

Audience: developers and operators of customer applications. Examples: C#/.NET, Node.js, Python, PHP, Redis Lua, and NGINX. Prepared September 5, 2026.

Your browser-visible proxy URL is public information. Anyone can discover /vindecode-v3-proxy, /vinfix-v3-proxy, /vinocr-v3-proxy, /vinbarcode-v3-proxy, or /lpr-v3-proxy from page source or the browser's network tools. Renaming a route, hiding JavaScript, or requiring a particular Referer does not make it private.

Authentication and authorization remain mandatory for private customer functionality. This guide adds controls that stop an authorized user from exhausting credits, constrain anonymous demo abuse, and protect the proxy's memory, processing capacity, and upstream credentials. The examples are integration components, not complete applications: they do not implement your identity provider, billing ledger, or deployment-specific firewall policy.

The controls below are recommendations for your proxy. They do not claim that every control is already deployed on VINquery's public demos. Your proxy must enforce these controls independently of any upstream-provider restrictions.

1. Choose the right protection model

Application Baseline beyond authentication Additional controls
Logged-in business application Per-user and per-account quotas; operation permissions; input limits Cost reservations, concurrency limits, CSRF protection for cookie sessions, anomaly alerts
Anonymous public demo Very small shared budget; limited functionality; edge limits Server-verified bot challenge, short-lived demo session, restricted examples, rapid disable switch
Server-to-server integration A separate credential for each caller; bounded request volume Private network or mTLS where practical, replay controls, outbound IP restrictions at your own edge
Internal administrative tool Role checks on every administrative operation Strong administrator authentication, audit trail, bounded bulk actions

A public demo cannot guarantee that every caller is a human using your intended UI. Decide how much anonymous use you can afford, then enforce that allowance. A challenge increases an attacker's effort; a hard, shared budget contains the damage when the challenge is defeated.

Keep demo and production credentials, budgets, logs, and feature switches separate. Do not let a public demo consume your customers' production allocation.

2. Put inexpensive checks before expensive work

Internet
  -> CDN/WAF: connection limits, request limits, body limits
  -> Trusted client-IP resolution and temporary deny rules
  -> Route, method, content-type and request-size checks
  -> Your authentication + tenant membership + operation permission
     OR the separately designed public-demo admission flow
  -> CSRF validation for cookie-authenticated requests
  -> Atomic shared user/account/global quota admission
  -> Bounded input parsing and image verification
  -> Durable cost reservation + idempotency record
  -> Bounded concurrency and queue admission
  -> Obtain/reuse the service-specific upstream token
  -> One fixed, validated VINquery request with a deadline
  -> Bounded response; settle reservation; release concurrency slot

These checks belong on the route that actually forwards the request, including any aliases. A login check on the HTML demo page does not protect a separate proxy endpoint. In frameworks with custom middleware, verify that the proxy is not handled before the authentication, CSRF, or rate-limiting middleware executes. A limiter attached to an unused controller or route provides no protection.

Do not obtain a fresh upstream token, buffer a large upload, or call VINquery before admission checks. Keep edge protection even when application-level admission is present: the rejected requests themselves still consume connections and CPU. OWASP recommends early, inexpensive validation and resource limits as part of denial-of-service protection. OWASP denial-of-service guidance

3. Define an explicit policy for each service

Use a server-owned service map. The following destinations reflect the portal integration source reviewed for this guide; confirm the current API contract and your provisioned audience before deployment.

Service Fixed upstream destination Input policy at your proxy
VINdecode https://vindecode.vinquery.com/v3 One VIN; allowlisted report type; fixed output format if possible
VINfix https://vinfix.recognition.ws/v3 One candidate VIN; reject unsupported length/characters before forwarding
VINocr https://vinocr.recognition.ws/v3 One bounded image; verified image encoding and dimensions
VINbarcode https://vinbarcode.recognition.ws/v3 One bounded image; verified image encoding and dimensions
LPR https://lpr.recognition.ws/v3 One bounded image; verify any optional parameters against an allowlist

For VINfix, do not reject every character that would be invalid in a correct VIN: its purpose includes correcting transcription errors. An initial ASCII check such as ^[A-Z0-9]{17}$, after trimming and uppercasing, follows the current VINfix handler's candidate-input behavior. Apply a service-specific policy to VINdecode rather than assuming the same permissiveness is appropriate everywhere.

Do not permit a caller to choose an arbitrary upstream URL, hostname, JWT audience, API consumer credential, tenant ID, or charging account. Resolve all of these from trusted server configuration and the user's verified membership. A user-selected VINdecode report type must be checked against that user's entitlement and cost policy.

Prefer POST on your browser-facing proxy for operations that consume credits, even when the upstream service accepts GET. Browser prefetching and cross-site navigation should not initiate paid work. You can translate your JSON POST into the upstream GET internally. This is a recommendation for new customer proxies, not a claim that existing demo GET URLs have changed.

4. Use several limits, not one IP counter

The following values are illustrative starting policies, not VINquery limits, pricing, or service guarantees. Establish your actual values from expected traffic, measured latency, contract limits, and acceptable financial exposure.

Dimension Example policy Purpose
Edge client IP 2 requests/second with a small burst Reduce repeated anonymous traffic before application work
Authenticated user 20 admitted operations/60-second window Prevent one user from monopolizing the account
Tenant/account 1,000 admitted operations/24-hour window Contain abuse across multiple users
Whole proxy deployment 10,000 admitted operations/24-hour window Stop account creation or route switching from bypassing your overall allowance
Image processing 2 active jobs per account; small bounded queue Limit slow-work saturation
Anonymous demo Much smaller separate daily allocation Bound spend even if bot checks are bypassed
Request body 8 KiB JSON; 5 MiB image with a slightly larger multipart envelope Bound memory, disk, and parsing effort

Combine a shared all-service quota with any per-service limits. Otherwise an attacker can multiply their allowance by alternating between the five endpoints. IPs are a secondary signal: offices share NAT addresses, mobile IPs change, and an attacker can rotate addresses or accounts.

Return 429 with Retry-After when a temporary quota is exhausted. Return 503 when the admission store is unavailable; do not silently forward without checks. Log the operational reason internally. Use one shared Redis service or admission service across workers and instances, rather than an in-memory counter that resets on restart or scales up with each replica.

Atomic admission shared by all four languages

The Lua component below checks three windows, and only increments them when all three admit the request. Redis runs scripts atomically with respect to other commands. Keep the script small; a long-running script blocks other Redis work. Redis scripting guarantees

All keys use the same {budget} hash tag, so this multi-key operation also fits Redis Cluster's single-slot requirement. That concentrates this small deployment's admission traffic on one slot. At large scale, use a dedicated admission service or a deliberate partitioning design; do not remove the hash tag and expect cross-slot atomicity.

The windows start with their first accepted request and expire after their configured durations. They are not sliding windows or UTC calendar-day budgets, and may allow closely spaced bursts across an expiry boundary. The edge limiter handles short bursts. For a hard rolling limit, choose a token bucket or sliding-window implementation.

-- KEYS: global, tenant, user counters (all in one Redis Cluster hash slot).
-- ARGV: limit, lifetime-seconds pairs. Each admission consumes one attempt.
-- Windows start at first admission; they are not sliding windows or money ledgers.
if #KEYS == 0 or #ARGV ~= #KEYS * 2 then
    return redis.error_reply('invalid admission arguments')
end
local counts = {}
local waits = {}
local retry = 0
for i, key in ipairs(KEYS) do
    local limit = tonumber(ARGV[2*i-1])
    local lifetime = tonumber(ARGV[2*i])
    if not limit or limit < 1 or limit ~= math.floor(limit)
        or not lifetime or lifetime < 1 or lifetime ~= math.floor(lifetime) then
        return redis.error_reply('invalid admission policy')
    end
    local raw = redis.call('GET', key)
    local count = tonumber(raw or '0')
    if not count or count < 0 or count ~= math.floor(count) then
        return redis.error_reply('invalid admission counter')
    end
    local ttl = redis.call('PTTL', key)
    if raw and ttl == -1 then
        return redis.error_reply('admission counter missing expiration')
    end
    counts[i] = count
    waits[i] = lifetime
    if count >= limit then
        retry = math.max(retry, math.max(1, math.ceil(ttl / 1000)))
    end
end
if retry > 0 then return {0, retry} end
for i, key in ipairs(KEYS) do
    redis.call('INCR', key)
    if counts[i] == 0 then redis.call('EXPIRE', key, waits[i]) end
end
return {1, 0}

Operational contract: use a dedicated key namespace, identical configuration and key secrets on all instances, Redis authentication/TLS, finite command deadlines, and an eviction policy that does not silently remove active admission counters. Reserve capacity for the store. Failover can lose recent writes depending on Redis persistence/replication settings; this counter is an abuse throttle, not a financial ledger. A lost response may still have consumed an admission: do not retry an uncertain admission command or refund blindly.

The code stores HMAC-derived identity keys. Use an independent random secret of at least 32 bytes, provisioned consistently through your secret manager. Do not use an upstream Client Secret as the HMAC key. Changing this secret or namespace resets the effective quotas, so coordinate rotation deliberately. The sample identifiers are bounded ASCII IDs; adapt validation to your identity system without replacing it with request-supplied values.

Node.js

guard.mjs accepts a connected node-redis client. Create one client at application startup, attach an error listener, disable offline queuing for admission operations, and configure finite command and connection deadlines for the client version you pin. Do not construct a client per request. node-redis documentation

import { createHmac } from 'node:crypto';

// redis: a connected node-redis client with an operation timeout and offline queue disabled.
// identity: ONLY from validated authentication + server-side tenant membership.
export async function admit(redis, script, secret, identity) {
  if (!Buffer.isBuffer(secret) || secret.length < 32) throw new Error('Missing key secret');
  const tenant = identity?.tenantId, user = identity?.userId;
  const valid = value => typeof value === 'string' && value.length >= 1 && value.length <= 128
    && !/[^A-Za-z0-9_.:@-]/.test(value);
  if (!valid(tenant) || !valid(user)) throw new Error('Missing trusted identity');
  const digest = value => createHmac('sha256', secret).update(value).digest('hex');
  const tenantPart = `${tenant.length}:${tenant}`;
  const keys = [
    'vqproxy:v1:{budget}:global',
    `vqproxy:v1:{budget}:tenant:${digest(tenantPart)}`,
    `vqproxy:v1:{budget}:user:${digest(tenantPart + user.length + ':' + user)}`
  ];
  // Illustrative ceilings across ALL five services, NOT published VINquery limits.
  const reply = await redis.eval(script, {
    keys, arguments: ['10000', '86400', '1000', '86400', '20', '60']
  });
  if (!Array.isArray(reply) || reply.length !== 2 || ![0, 1].includes(Number(reply[0])))
    throw new Error('Invalid admission response');
  const retryAfter = Number(reply[1]);
  if (!Number.isInteger(retryAfter) || retryAfter < 0) throw new Error('Invalid retry delay');
  return { allowed: Number(reply[0]) === 1, retryAfter };
}

Route integration, after your mandatory authentication, authorization, CSRF and bounded JSON parsing middleware:

// req.auth is set by your validated authentication/membership middleware.
// Never substitute req.body.tenantId, req.query.userId or an untrusted header.
const vin = typeof req.body?.vin === 'string' ? req.body.vin.trim().toUpperCase() : '';
if (!/^[A-Z0-9]{17}$/.test(vin)) {
  return res.status(400).json({ error: 'Invalid VIN input.' });
}
let admission;
try {
  admission = await admit(redis, admissionScript, keySecret, req.auth);
} catch {
  return res.status(503).json({ error: 'Temporarily unavailable.' });
}
if (!admission.allowed) {
  return res.set('Retry-After', String(admission.retryAfter))
    .status(429).json({ error: 'Request limit reached.' });
}
// Next: durable cost/idempotency admission and bounded concurrency.
// Only then call the fixed upstream using a server-owned token.

This fragment belongs inside an async Express route. Configure express.json({limit: '8kb', inflate: false}) for this route, handle parser errors as 400/413, and reject unsupported content encodings explicitly. Authentication middleware must reject absent identity with 401 before this fragment; the guard's missing-identity exception is defense in depth, not the login flow.

Python

Use a reusable redis.asyncio client with finite connection/socket timeouts and no automatic retry of admission calls. redis-py asyncio documentation

import hashlib
import hmac
import re


async def admit(redis, script: str, secret: bytes, identity: dict) -> tuple[bool, int]:
    """redis.asyncio client; callers MUST catch outages and reject before forwarding."""
    if not isinstance(secret, bytes) or len(secret) < 32:
        raise ValueError("Missing key secret")
    tenant, user = identity.get("tenant_id"), identity.get("user_id")
    if not all(isinstance(v, str) and re.fullmatch(r"[A-Za-z0-9_.:@-]{1,128}", v)
               for v in (tenant, user)):
        raise ValueError("Missing trusted identity")
    def digest(value):
        return hmac.new(secret, value.encode("utf-8"), hashlib.sha256).hexdigest()
    tenant_part = f"{len(tenant)}:{tenant}"
    keys = ["vqproxy:v1:{budget}:global",
            "vqproxy:v1:{budget}:tenant:" + digest(tenant_part),
            "vqproxy:v1:{budget}:user:" + digest(tenant_part + f"{len(user)}:{user}")]
    reply = await redis.eval(script, len(keys), *keys, 10000, 86400, 1000, 86400, 20, 60)
    if not isinstance(reply, (list, tuple)) or len(reply) != 2 or reply[0] not in (0, 1):
        raise RuntimeError("Invalid admission response")
    if not isinstance(reply[1], int) or reply[1] < 0:
        raise RuntimeError("Invalid retry delay")
    return reply[0] == 1, reply[1]

An async handler can use the component as follows; identity must already be resolved by the framework's authentication and authorization dependency:

try:
    allowed, retry_after = await admit(redis, script, key_secret, identity)
except Exception:
    # Record a sanitized admission-store failure in your server logs.
    raise HTTPException(status_code=503, detail="Temporarily unavailable.")
if not allowed:
    raise HTTPException(status_code=429, detail="Request limit reached.",
                        headers={"Retry-After": str(retry_after)})
# Acquire the budget reservation and concurrency slot before forwarding.

HTTPException above is from FastAPI. Enforce body limits at the reverse proxy and in ASGI middleware before framework JSON/multipart parsing; a Pydantic field constraint alone does not limit raw-body memory use. Use httpx.AsyncClient with TLS verification, follow_redirects=False, finite timeouts, and bounded streaming reads. Connection/read timeouts are not always a total-operation deadline: add an outer deadline where needed.

C# / ASP.NET Core

The component uses a small Redis adapter so the policy code does not depend on your web framework. Target .NET 8 or a supported later runtime.

using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

public static class ProxyGuard
{
    // eval is the Redis client adapter shown in the guide; never a local counter.
    public static async Task<(bool Allowed, int RetryAfter)> AdmitAsync(
        Func<string, string[], long[], Task<long[]>> eval,
        string script, byte[] secret, string tenant, string user)
    {
        if (secret == null || secret.Length < 32) throw new ArgumentException("Missing key secret");
        if (tenant == null || user == null ||
            !Regex.IsMatch(tenant, @"\A[A-Za-z0-9_.:@-]{1,128}\z") ||
            !Regex.IsMatch(user, @"\A[A-Za-z0-9_.:@-]{1,128}\z"))
            throw new ArgumentException("Missing trusted identity");
        string Digest(string value) => Convert.ToHexString(
            HMACSHA256.HashData(secret, Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
        var tenantPart = tenant.Length + ":" + tenant;
        var keys = new[] {
            "vqproxy:v1:{budget}:global",
            "vqproxy:v1:{budget}:tenant:" + Digest(tenantPart),
            "vqproxy:v1:{budget}:user:" + Digest(tenantPart + user.Length + ":" + user)
        };
        var reply = await eval(script, keys, new long[] {10000, 86400, 1000, 86400, 20, 60});
        if (reply.Length != 2 || (reply[0] != 0 && reply[0] != 1) || reply[1] < 0 || reply[1] > int.MaxValue)
            throw new InvalidOperationException("Invalid admission response");
        return (reply[0] == 1, (int)reply[1]);
    }
}

Wire it to a singleton StackExchange.Redis connection; obtain IDatabase from that connection. Do not substitute a process-local dictionary in production. StackExchange.Redis scripting

// using StackExchange.Redis;
// using System.Linq;
async Task<long[]> Eval(string script, string[] keys, long[] args)
{
    var result = await db.ScriptEvaluateAsync(script,
        keys.Select(k => (RedisKey)k).ToArray(),
        args.Select(a => (RedisValue)a).ToArray());
    return ((RedisResult[])result!).Select(v => (long)v).ToArray();
}

// Within your authenticated/authorized endpoint, after CSRF and input checks:
try
{
    var decision = await ProxyGuard.AdmitAsync(Eval, script, keySecret, tenantId, userId);
    if (!decision.Allowed)
    {
        context.Response.Headers["Retry-After"] = decision.RetryAfter.ToString();
        context.Response.StatusCode = 429;
        await context.Response.WriteAsJsonAsync(new { error = "Request limit reached." });
        return;
    }
}
catch (Exception)
{
    context.Response.StatusCode = 503;
    await context.Response.WriteAsJsonAsync(new { error = "Temporarily unavailable." });
    return;
}
// Continue through budget/concurrency admission, then the fixed upstream call.

Configure Redis operation timeouts and an appropriate backlog policy so a disconnected admission command does not execute unexpectedly much later. On timeout, reject the request without forwarding. With ASP.NET Core, enforce the body limit before ReadFormAsync or model binding; align Kestrel, IIS, reverse-proxy, and multipart limits. Use an IHttpClientFactory client with redirects disabled and pass a bounded cancellation token through send and response-body reads.

PHP

Use the phpredis extension and a connection with finite connect/read timeouts. phpredis project documentation

<?php
declare(strict_types=1);

// A connected phpredis client with finite connection/read timeouts.
// $tenant and $user come from validated server-side identity, never request fields.
function admit(Redis $redis, string $script, string $secret, string $tenant, string $user): array
{
    if (strlen($secret) < 32) throw new InvalidArgumentException('Missing key secret');
    if (!preg_match('/\A[A-Za-z0-9_.:@-]{1,128}\z/', $tenant) ||
        !preg_match('/\A[A-Za-z0-9_.:@-]{1,128}\z/', $user)) {
        throw new InvalidArgumentException('Missing trusted identity');
    }
    $tenantPart = strlen($tenant) . ':' . $tenant;
    $keys = [
        'vqproxy:v1:{budget}:global',
        'vqproxy:v1:{budget}:tenant:' . hash_hmac('sha256', $tenantPart, $secret),
        'vqproxy:v1:{budget}:user:' . hash_hmac('sha256', $tenantPart . strlen($user) . ':' . $user, $secret)
    ];
    $reply = $redis->eval($script, array_merge($keys, [10000, 86400, 1000, 86400, 20, 60]), 3);
    if (!is_array($reply) || count($reply) !== 2 || !in_array($reply[0], [0, 1], true) ||
        !is_int($reply[1]) || $reply[1] < 0) {
        throw new RuntimeException('Invalid admission response');
    }
    return ['allowed' => $reply[0] === 1, 'retryAfter' => $reply[1]];
}

Place this in the proxy handler after session/permission/CSRF checks and bounded input validation:

try {
    $decision = admit($redis, $script, $keySecret, $tenantId, $userId);
} catch (Throwable $error) {
    http_response_code(503);
    header('Content-Type: application/json');
    header('Cache-Control: no-store');
    echo json_encode(['error' => 'Temporarily unavailable.']);
    exit;
}
if (!$decision['allowed']) {
    http_response_code(429);
    header('Retry-After: ' . $decision['retryAfter']);
    header('Content-Type: application/json');
    header('Cache-Control: no-store');
    echo json_encode(['error' => 'Request limit reached.']);
    exit;
}
// Reserve budget and acquire bounded concurrency before cURL forwarding.

Align post_max_size, upload_max_filesize, web-server body limits and file counts. Set cURL connect and total timeouts, CURLOPT_FOLLOWLOCATION=false, and retain peer/hostname verification. Bound response bytes with a write callback. Never construct a shell command from a VIN, filename, or URL. If PHP's session lock is held, release it after copying and validating the identity/CSRF context so one slow upstream call does not serialize every request in that user's session.

5. Configure the edge and trustworthy client IPs

The NGINX fragment applies admission pressure to all five named routes before the application receives them. Use your real hostname, certificate paths, and internal application address. Its shared-memory limits apply across workers on that NGINX instance, not automatically across multiple edge servers. The Redis component supplies deployment-wide identity quotas. NGINX request limiting, connection limiting

# Merge into your existing HTTPS configuration. These rates are examples.
# The application listens on loopback; do not expose port 5254 publicly.
# If a CDN precedes NGINX, configure real_ip ONLY for its verified CIDRs first.
limit_req_zone $binary_remote_addr zone=proxy_ip_rate:10m rate=2r/s;
limit_conn_zone $binary_remote_addr zone=proxy_ip_concurrency:10m;

upstream client_proxy_app { server 127.0.0.1:5254; }

server {
    listen 443 ssl;
    server_name app.example.com;
    ssl_certificate /etc/nginx/tls/fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/privkey.pem;

    location ~ ^/(vindecode|vinfix|vinocr|vinbarcode|lpr)-v3-proxy$ {
        limit_req zone=proxy_ip_rate burst=5 nodelay;
        limit_req_status 429;
        limit_conn proxy_ip_concurrency 3;
        limit_conn_status 429;
        client_max_body_size 6m;
        client_body_timeout 10s;
        proxy_connect_timeout 3s;
        proxy_send_timeout 15s;
        proxy_read_timeout 30s;
        proxy_next_upstream off;
        proxy_request_buffering on;
        proxy_pass http://client_proxy_app;
        proxy_set_header Host app.example.com;
        proxy_set_header X-Forwarded-Proto $scheme;
        # Replace untrusted inbound forwarding headers, do not append their contents.
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Forwarded "";
        add_header Cache-Control "no-store" always;
    }
}

A regex location is not sufficient if another location, alternate hostname, trailing-slash alias, or direct application port bypasses it. Align the edge's route matching with your application's case and slash behavior. Test every supported alias and the origin IP. Configure your existing HTTP listener to redirect to HTTPS, and ensure the application cannot be reached directly from the Internet.

Do not take the first X-Forwarded-For value from an arbitrary request. Configure only the actual proxy addresses as trusted, strip inbound replacement headers at the edge, and resolve the client address through that trusted chain. If a CDN fronts NGINX, update the CDN's verified address ranges and restrict origin access accordingly. Never trust 0.0.0.0/0 as a forwarding proxy. NGINX real-IP configuration

Normalize IPv4-mapped IPv6 before matching deny rules or forming IP rate keys. A temporary IP block can help contain a visible attack, but account/session limits still apply when an attacker changes IP. Set block expiry, a reason and an audit trail; provide an operator recovery path and document cache propagation delay. Your own proxy's client-IP rules are separate from an upstream provider's outbound-IP allowlist: an upstream allowlist cannot distinguish good and abusive requests that both come through your allowed proxy.

6. Bound uploads before OCR, barcode, or LPR work

Accept one required inputimage, not unlimited files or arbitrary remote image URLs. Apply total-body, individual-file, field-count and field-length limits while reading the stream, including chunked requests without Content-Length. Do not trust that header, a filename suffix, or the browser's declared MIME type as proof of safety.

Decode using a maintained image library in a bounded worker; allow only formats needed by your product, check dimensions and total decoded pixels, reject unsupported multi-frame images, and re-encode accepted images where suitable. Use server-generated temporary names outside the web root and delete them in cleanup paths. An apparently small compressed image can be expensive to decode. OWASP file-upload guidance

Example policy: 5 MiB encoded image, at most 20 million decoded pixels, one frame, JPEG/PNG only, and no SVG/PDF/archive support unless you deliberately implement their separate handling. These are application choices, not guaranteed VINquery acceptance limits. Verify that re-encoding preserves the detail needed for recognition.

Language-specific placement matters: Express multipart middleware, ASP.NET model binding, PHP upload parsing and ASGI multipart parsing may run before your handler. Configure their limits as well as the edge; adding if file.size > limit after an unbounded upload has already been buffered is too late.

7. Protect credits with durable reservations and bounded retries

A request-count throttle cannot promise a dollar cap when services have different costs or retries can cause duplicate charges. Maintain a transactional budget ledger for each tenant and for your deployment. Price the requested operation from server-owned configuration in integer credit units; never trust a price, account, balance, or report entitlement supplied by the browser.

Suggested transaction design:

begin transaction
  lock the tenant's budget row
  look up (tenant, operation, idempotency_key)
    if found with another request hash: reject conflict
    if completed: return its authorized, unexpired result
    if pending/uncertain: return existing status; do not forward again
  ensure spent + reserved + maximum_operation_cost <= limit
  reserve maximum_operation_cost
  insert unique pending operation with normalized request hash
commit

perform the upstream call once, under a bounded concurrency slot

begin transaction
  lock this operation; settle it exactly once
  known outcome: charge/release according to the actual billing contract
  unknown outcome: mark uncertain and keep a conservative reservation
commit

Use a unique database constraint on the idempotency tuple, not just an application-side lookup. Include service, authorized report type, normalized input, and the relevant processing version in the hash. For uploads, hash the bounded bytes. Resolve duplicates after current authorization checks; do not let cached results bypass permissions. Limit key length and retained records to avoid an idempotency-storage attack.

Do not assume VINquery supports an upstream idempotency header without an explicit API guarantee. Your local idempotency record prevents duplicate forwarding under your control; it cannot make a timed-out upstream transaction un-happen. A lost response may follow successful processing and billing. Keep the operation uncertain, reconcile if possible, and avoid automatically retrying potentially paid work. Also disable automatic retries at the edge and in SDK/resilience policies unless the operation is demonstrably safe.

Bound active upstream calls separately from rates: two per account and a small deployment-wide pool may be a starting point. Use a bounded shared work queue or distributed lease mechanism. Release slots on every exit path; give crash-recovery leases a timeout longer than the enforced operation deadline, with safe renewal if needed. A lease that expires while work still runs permits excess concurrency. Do not start an unlimited background task after the client disconnects.

Enforce a service/tenant disable switch before any token request or paid call. A switch should disable work, not bypass security checks. Alert before budget exhaustion and provide an operator-only way to raise limits after investigation.

8. Public demos: bot checks and a small independent allowance

For an anonymous demo, start with edge limits and a small shared quota, then validate a bot-challenge token on your server before expensive work. Never trust a browser flag such as captchaPassed=true. Check the verification response's success, expected hostname and action, and bind any resulting demo grant to a short-lived session and permitted operation.

For example, Cloudflare Turnstile requires server-side verification; its tokens expire after five minutes and are single-use. Do not reuse a successful token for an unlimited session or accept a challenge solved for a different operation. Turnstile server-side verification

// Run only after edge/request limits. No secret is sent to the browser.
async function verifyDemoChallenge(token, secret, expectedHost) {
  if (typeof token !== 'string' || token.length === 0 || token.length > 2048) return false;
  try {
    const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
      method: 'POST', redirect: 'error', signal: AbortSignal.timeout(3000),
      body: new URLSearchParams({ secret, response: token })
    });
    if (!response.ok) return false;
    const result = await response.json();
    return result.success === true && result.hostname === expectedHost
      && result.action === 'vinquery_demo';
  } catch { return false; }
}

Set the widget's action to the same server-expected value. Rate-limit verification attempts as well, so your challenge provider does not become a new exhaustion target. Keep hostname/action configuration server-owned and verify missing fields as failures. A challenge is one signal, not an identity or an authorization grant.

A lower-risk demo can accept only predefined sample VINs/images and return prepared sample results. If you permit arbitrary input, use a separate tiny allowance and a global stop switch. An anonymous session cookie or IP limit alone is easy to reset or evade; a global demo budget remains necessary.

9. Browser protections and credential boundaries

For cookie-authenticated requests, use the framework's CSRF protection and validate the token at the proxy endpoint. Use secure cookie settings and exact-origin CORS rules for intended browser clients. SameSite cookies and Origin/Fetch Metadata checks provide additional browser protections; they do not authenticate scripts outside a browser. Protect requests that consume credits even when they look like reads. OWASP CSRF guidance

Never embed a shared proxy password, API key, signing secret, or upstream JWT in JavaScript. Anything shipped to the browser can be extracted. CORS controls browser access; it does not prevent curl, bots, or another server from reaching your public URL. A Referer header or a JavaScript-generated signature using a browser-held secret is not a substitute for authentication.

For server-to-server callers, a private network or mTLS can provide stronger caller binding. If using signed requests, use a standard scheme or maintained library with timestamps, a body digest and atomically consumed nonces; a timestamp without a nonce permits replays during its validity window. Keep separate credentials and revocation paths for each integration.

Keep upstream credentials and cached JWTs in server-only storage. Cache by the correct tenant/credential/audience tuple, with an expiry safety margin and a single-flight refresh mechanism. Do not share one customer's token with another tenant or request a fresh token for every browser call. Restrict runtime access to secrets and never log token responses. OWASP secrets-management guidance

10. Prevent the proxy from becoming an open relay

Build upstream requests from fixed destinations and allowlisted parameters. Disable redirects so a configured API response cannot redirect a credential-bearing request to another host. Keep TLS hostname/certificate validation enabled and use outbound network restrictions where feasible. Do not implement /proxy?url=... or fetch arbitrary caller-supplied image URLs. OWASP SSRF prevention

Your incoming application session token and the outgoing VINquery JWT have different purposes. Do not forward the browser's Authorization header as the upstream credential in a normal customer proxy. Select the appropriate VINquery credential on the server after validating the caller. If supporting a distinct bring-your-own-credential integration, isolate and document that separate trust model.

Limit upstream response bytes, total duration, and simultaneous calls. For a JSON-only proxy, require an expected JSON response and reject unexpected HTML with a generic upstream-error response. Do not render raw upstream HTML or exception text into the customer's page. If parsing XML, prohibit external entity resolution. Set Cache-Control: no-store on private results and errors unless you have deliberately designed a tenant-scoped cache.

11. Logging, alerts, and incident response

Record request ID, trusted tenant/user identifier, service, decision category, status, duration, admission result, and cost/reservation outcome. Keep the internal cause of denial out of public responses. Redact credentials, cookies, Authorization headers, token responses, and uploaded image bytes. VINs, plate images and IPs can be sensitive: minimize collection, restrict access and choose an appropriate retention policy. Bound or sample repetitive rejection logs so an attacker cannot fill storage. OWASP logging guidance

Suggested signals: a sudden increase in admissions or spend; many users sharing a credential; many IPs using one user account; repeated malformed uploads; growing latency/queue depth; token-refresh spikes; missing admission-store connectivity; and a large gap between accepted proxy operations and upstream billing records.

Contain an incident by disabling the affected tenant/service/demo, revoking the compromised caller session or credential, and tightening a temporary edge limit. Inspect pending/uncertain operations before replaying them. Rotate upstream secrets if exposure is suspected. Preserve relevant audit records, repair the entry point, and re-enable gradually. Do not automatically permanently ban everyone behind a shared office IP because of one burst.

12. Test the controls before exposing the proxy

Run these checks in your own staging environment using a stub upstream that counts calls. Do not load-test a public demo or production API without authorization. The most important assertion is that rejected requests generate zero upstream calls.

Test Expected result
No authentication on a private proxy 401; zero token requests and zero paid calls
Valid user without service/tenant permission 403; no upstream call
Forged tenant ID, audience, credential selector or destination Ignored or rejected; server-owned routing and billing remain unchanged
100 parallel requests with a 20-admission limit across two replicas At most 20 admissions in the active window
Calls distributed across all five services Shared quota still applies
Forged forwarding headers or direct-origin request Cannot change the trusted IP or bypass the edge
Failed Redis request, timeout, or malformed counter Generic 503; no forwarding; operational alert
Missing/wrong CSRF token with session cookies Rejected before paid work
Reused, expired, wrong-action or wrong-host challenge token Demo admission rejected
Oversized chunked body, multiple files, giant decoded image Bounded rejection, no upstream call and no leftover temporary file
Repeated idempotency key with different input Conflict; no second paid call
Upstream accepts work but response is lost Operation stays uncertain; no automatic duplicate
Redirect to another host or unexpected HTML response No credential forwarding to redirected host; no raw HTML rendering
Caller disconnects, process crashes, or deadline expires Work remains bounded; concurrency and reservation recovery follow policy
Route casing, trailing slash, alias and alternate hostname Every supported entry point passes the same controls
Budget almost exhausted and concurrent requests arrive Transactional reservations cannot overspend the configured ceiling

Adoption order

First, lock down destinations, credentials, methods and request sizes; confirm every real proxy route passes your existing authentication and authorization. Next, add edge limits and shared admission quotas. Before meaningful paid usage, implement cost reservations, concurrency control and safe retry behavior. Add the separate demo admission flow, monitoring, and documented incident handling. Run the staging tests above, start with a deliberately small allowance, and tune from actual legitimate traffic.

Example status and integration responsibilities

The Redis Lua policy and four language adapters are original example code for this guide. The small handlers show integration points rather than complete identity, billing or upload implementations. Run the included checks and your own distributed integration tests before adopting them. Do not mistake successful syntax/unit checks for a production security review. Package and runtime versions should be pinned and maintained under your normal dependency policy.

See VALIDATION.md in the downloadable source package for the checks actually performed and any untested components. See your VINquery integration documentation for token issuance, audience values and service-specific request schemas.