ID scans and signed contracts: authorize the request, not the URL

Two separate guarantees, two mechanisms. How to make a forwarded link worthless, what Infrai's image routes enforce for you, and the tenant check that stays yours.

These are two guarantees, and conflating them is how document stores leak. “Only authorized users” is a decision your server makes on every single read, against the user in the session and the row in your database. “A forwarded link doesn’t leak” is a property of the artefact you hand out — and it holds only when the link is not itself the credential. Infrai’s stored images have no public URL at all: reads go through GET /v1/image/get/{id} carrying your server-side key, so there is nothing a user can paste into a group chat that would work.

That default matters more than any expiry setting. A signed CDN URL is a bearer credential with a timer on it; shortening the timer narrows the window but never changes what the artefact is. For a passport scan, the difference between “leaks for 15 minutes” and “cannot leak” is the whole question.

What each access model actually gives a recipient

ModelWhat the recipient needsWhat a forward yieldsRevocable mid-lifeAudit granularity
Public bucket URLthe URLthe document, forevernonone
Long-lived signed URLthe URLthe document until expirynoone line at issue time
Short-TTL signed URLthe URLthe document for minutesnot reallyone line at issue time
Server-proxied reada session your app trustsa 403yes, instantlyevery read, with subject

The bottom row is the one to build. It costs you a hop and it means your service is in the data path, which some teams dislike — but it’s the only row where “who looked at this contract, and when” is a query rather than a guess.

The check that runs on every read

Four predicates, evaluated together, before a single byte moves:

  1. Subject — there’s an authenticated session, not an API token shared by a team.
  2. Relationship — this subject owns, counter-signs, or administers this document id. Not “is logged in”.
  3. Purpose — the document is being read in a step that needs it. A signature request that closed last March does not need to re-open the ID scan.
  4. Recency — for the sensitive classes, re-authentication within the last few minutes, not a six-month-old cookie.

Predicate 2 is where the interesting bugs live. If your handler takes a document id from the URL and doesn’t join it back to the caller, you’ve built an enumerable API and the only thing protecting you is the length of the id.

// document-read.mjs — Node 22
import { createServer } from "node:http";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY");

// Stand-in for your database. In a real service these are rows with a tenant id.
const documents = new Map([
  ["doc_8812", { imageId: "pim_a42ac9740a20ad3041bdc3b2", owner: "user_42", kind: "id_scan", closedAt: null }],
]);
const sessions = new Map([["sess_live", { userId: "user_42", authenticatedAt: Date.now() }]]);

const RECENCY_MS = 5 * 60 * 1000;

function authorize(sessionId, docId) {
  const session = sessions.get(sessionId);
  if (!session) return { allow: false, reason: "no_session" };
  const doc = documents.get(docId);
  if (!doc) return { allow: false, reason: "no_document" };
  if (doc.owner !== session.userId) return { allow: false, reason: "not_owner" };
  if (doc.closedAt) return { allow: false, reason: "case_closed" };
  if (doc.kind === "id_scan" && Date.now() - session.authenticatedAt > RECENCY_MS) {
    return { allow: false, reason: "reauth_required" };
  }
  return { allow: true, doc, subject: session.userId };
}

function audit(record) {
  // Ship this to your log sink; the point is that it exists for every read, allowed or not.
  console.log(JSON.stringify({ at: new Date().toISOString(), ...record }));
}

createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  const docId = url.searchParams.get("doc") ?? "";
  const verdict = authorize(req.headers["x-session"] ?? "", docId);
  audit({ event: "document_read", doc: docId, allow: verdict.allow, reason: verdict.reason ?? "ok", subject: verdict.subject });
  if (!verdict.allow) { res.writeHead(403, { "content-type": "application/json" }); res.end(JSON.stringify({ error: verdict.reason })); return; }

  const upstream = await fetch(`https://api.infrai.cc/v1/image/get/${verdict.doc.imageId}`, {
    headers: { authorization: `Bearer ${KEY}` },
  });
  const payload = await upstream.json();
  if (!upstream.ok || payload.ok === false) {
    audit({ event: "document_missing", doc: docId, code: payload?.error?.code });
    res.writeHead(502).end("upstream unavailable");
    return;
  }
  const bytes = Buffer.from(payload.data.url.split(",")[1], "base64");
  res.writeHead(200, { "content-type": `image/${payload.data.format}`, "cache-control": "private, no-store" });
  res.end(bytes);
}).listen(8080, () => console.log("reading documents on :8080"));

Note what the handler never does: it never puts the asset id in a response, never redirects the browser upstream, and never caches. The user’s browser learns nothing it could replay.

The read is key-gated, and you can check that in one command

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/image/get/pim_a42ac9740a20ad3041bdc3b2" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.data | {image_id, format, width, height, created_at}'

Drop the header and the same request returns HTTP 401:

{
  "ok": false,
  "error": {
    "code": "UNAUTHORIZED",
    "http_status": 401,
    "message": "Invalid Project Key.",
    "code_detail": "no_bearer",
    "retryable": false
  }
}

Here’s the caveat that governs your design, though: the key is scoped to the account, not to a document or a tenant. Anything holding that key reads any image_id the account owns. There’s no per-object ACL to lean on, so predicate 2 above isn’t optional decoration — it’s the actual isolation boundary, and it lives in your code.

Documents describe themselves more than you’d like

A photographed ID carries EXIF. POST /v1/image/metadata is a free read that shows you exactly what came along for the ride:

SCAN=$(base64 < ./passport.jpg | tr -d '\n')

curl -sS -X POST "https://api.infrai.cc/v1/image/metadata" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "{\"image\":{\"base64\":\"${SCAN}\"}}" | jq '.data | {width, height, format, exif}'

Camera make, model and orientation come back as numbered tags; on phone captures you’ll frequently find location tags too, which is a home address attached to an identity document. Re-encoding through the pipeline drops them — the output of a transform carried "exif": null in our testing every time.

Mark the copy you serve

Screenshots defeat every access control ever built, so make the screenshot self-incriminating. A watermark op burns the viewer’s identity and timestamp into the rendered copy at request time:

curl -sS -X POST "https://api.infrai.cc/v1/image/process" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "{\"image\":{\"base64\":\"${SCAN}\"},\"ops\":[{\"op\":\"watermark\",\"params\":{\"text\":\"user_42 · 2026-07-26 · doc_8812\",\"position\":\"bottom-right\",\"opacity\":0.4}}],\"format\":\"jpeg\"}"
{
  "ok": true,
  "data": {
    "image_id": "pim_563ea0a542396d9550525781",
    "format": "jpeg",
    "width": 1200,
    "height": 900,
    "size_bytes": 7718,
    "ops_applied": ["watermark(text,pos=bottom-right,alpha=0.4)", "format_convert(jpeg,q=90)"]
  }
}

Since the transform doesn’t retain anything unless you ask, the marked copy exists for exactly one response.

Erasure you can evidence

When a contract’s retention clock runs out, DELETE /v1/image/delete/{id} reports what it did rather than returning a bare 204, so your compliance record can quote a result instead of an assumption:

{
  "ok": true,
  "data": { "deleted": true, "image_id": "pim_ee2e4a1c5a3f42a1e26c8293" }
}

A deleted: false means there was nothing retained under that id — usually because the transform ran without store, which for sensitive documents is the desirable outcome.

Costs, and the parts this doesn’t solve

Verified 2026-07-26: POST /v1/image/metadata, POST /v1/image/process, GET /v1/image/get/{id} and DELETE /v1/image/delete/{id} are free rate-limited calls. The billable neighbours are POST /v1/image/compress at $0.003 per call and POST /v1/image/background_remove at $0.05. New accounts carry $2 of credit. These rates drift downward, so read the current ones:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '[.capabilities[] | select(.namespace == "image") | {id, price: .billing.price_usd}]'

Three limitations worth stating plainly. Text extraction from a signed contract via POST /v1/image/ocr answers VENDOR_NOT_CONFIGURED with HTTP 503 on this account today, so contract parsing needs a separate vision vendor. There’s no data-residency guarantee to quote you here, which matters if a regulator has opinions about where an identity document sits. And this is a set of primitives, not a compliance product — retention holds, DLP scanning and eDiscovery are what Kiteworks-class platforms sell, and if an auditor is asking for those you’d be better off buying one.

If instead your files are marketing assets that merely shouldn’t be hotlinked, this is heavy machinery: Cloudinary’s access-control modes and imgix’s secure URLs do signed delivery at CDN speed, and that’s the right tool for content whose worst-case exposure is embarrassment. The reason to run identity documents through Infrai is that the surrounding parts — the queue that processes them, the audit log sink, the error tracking on a failed read, the per-tenant usage view — sit on the same key and the same bill, so the security boundary doesn’t fragment across four vendors’ consoles.

References

Browse more image developer guides