GuardID documentation
GuardID stamps an invisible, verifiable signature into PNG images. Use the @yavqolabs/guardid npm package to run it inside your own app — in Node or the browser — or call the HTTP API hosted on this site. Both share the same key and bit format, so anything protected one way verifies the other.
Install
npm install @yavqolabs/guardidZero runtime dependencies. Requires Node.js >= 18 (for the Web StreamsCompressionStream / DecompressionStream globals) or any modern browser. Ships as ESM with full TypeScript types.
The hosted API needs no install — see HTTP API.
npm package — @yavqolabs/guardid
Quick start
import { protect, verify } from "@yavqolabs/guardid";
import { readFile, writeFile } from "node:fs/promises";
// Stamp an image with a fresh, unique ID
const pngBytes = await readFile("photo.png");
const { png, id } = await protect(pngBytes);
await writeFile("photo.watermarked.png", png);
console.log(id); // "GuardID · 2026-11-01 · A7F3C2D1"
// Later, anywhere, with no secret of your own
const found = await verify(await readFile("photo.watermarked.png"));
// → "GuardID · 2026-11-01 · A7F3C2D1" (or null if unprotected / tampered)Inputs accept Uint8Array, ArrayBuffer, Blob (so browser File objects work directly), or an already-decoded Raster.
protect(input, options?)
Decodes the PNG, embeds the payload into the least-significant bits of every R/G/B channel, and re-encodes. The output is visually identical — max ±1/255 per channel.
| Option | Type | Default | Description |
|---|---|---|---|
| options.id | string | generated | Custom payload to stamp instead of generateWatermarkId(). |
| options.key | string | APP_KEY | Scrambling key. Pass your own to create a private namespace invisible to the default detector. |
Returns Promise<ProtectResult>:
| Field | Type | Description |
|---|---|---|
| png | Uint8Array | Watermarked image, PNG-encoded, ready to write. |
| id | string | The exact payload stamped into the image. |
| bitsUsed | number | Bits consumed by the signature (48 header + 8 per payload byte). |
| capacityBits | number | Total LSB capacity: width × height × 3. |
verify(input, options?)
Reads the hidden bits, unscrambles them with the key and checks the GID1 magic header. Returns the stamped ID string, or null when the image carries no GuardID signature for that key — wrong key, re-encoding, or tampering all resolve to null rather than throwing.
// Browser: verify a File dropped by the user
const id = await verify(droppedFile);
if (id) console.log(`Genuine GuardID image: ${id}`);Low-level API
| Export | Description |
|---|---|
| generateWatermarkId() | Returns a fresh "GuardID · YYYY-MM-DD · XXXXXXXX" ID. |
| APP_KEY | The built-in signature key shared by the package, the API and the web studio. |
| embed(raster, payload, key) | Bit-level embedding on a decoded Raster; returns new RGBA bytes plus usage stats. |
| extract(raster, key) | Bit-level extraction; returns the payload string or null. |
| decodePng(bytes) / encodePng(raster) | The zero-dependency PNG codec (8-bit, non-interlaced RGB/RGBA). |
import { decodePng, encodePng, embed, extract, APP_KEY } from "@yavqolabs/guardid";
const raster = await decodePng(pngBytes);
const stamped = embed(raster, "acme-studio · shot-042", APP_KEY);
const png = await encodePng({ ...raster, rgba: stamped.rgba });
const back = extract(await decodePng(png), APP_KEY); // "acme-studio · shot-042"HTTP API
Hosted on this site under /api/v1. No authentication, no limits beyond size. Both endpoints accept the same two request shapes: raw PNG bytes, or JSON with a base64 / data-URL image.
POST/api/v1/watermark
Request — either form:
# raw PNG bytes
curl -X POST https://<your-deployment>/api/v1/watermark \
-H "Content-Type: image/png" --data-binary @photo.png
# JSON with base64 (or a data: URL)
curl -X POST https://<your-deployment>/api/v1/watermark \
-H "Content-Type: application/json" \
-d '{ "image": "'"$(base64 -i photo.png)"'" }'Response 200:
{
"id": "GuardID · 2026-11-01 · A7F3C2D1",
"png": "<base64 watermarked PNG>",
"bitsUsed": 312,
"capacityBits": 28800
}POST/api/v1/verify
Same request shapes. Response 200:
{ "watermark": "GuardID · 2026-11-01 · A7F3C2D1", "protected": true }An unprotected or altered image returns { "watermark": null, "protected": false }.
Errors
| Status | When |
|---|---|
| 400 | Invalid JSON, missing/empty image field, or bad base64. |
| 413 | Decoded image larger than 25 MB. |
| 415 | Unsupported Content-Type (send image/png or application/json). |
| 422 | Not a PNG, or an unsupported PNG variant (palette, 16-bit, interlaced), or the payload exceeds image capacity. |
GET/api/v1/watermark · /api/v1/verify
Both endpoints answer GET with a JSON usage summary.
Watermark format
The payload is framed as a 32-bit magic header ("GID1") + 16-bit payload length + UTF-8 payload bytes. That bit stream is XOR-scrambled with a deterministic keystream seeded from the key (xmur3 hash → mulberry32 PRNG), then written one bit into the LSB of each R, G and B channel in raster order. Alpha is never touched.
Capacity is width × height × 3 bits — a 1000×1000 image holds ~3M bits, roughly 375 KB of payload. Verification is all-or- nothing: without the key, or after any pixel damage to the first bytes, the magic header never matches and the watermark is undetectable.
Limits & best practices
The default APP_KEY is public by design — any installable detector must be reproducible. It provides signature integrity (you can't forge it without the key), not secrecy. For a private namespace, pass your own key.
Supported inputs: 8-bit, non-interlaced RGB or RGBA PNGs. Grayscale, palette, 16-bit and Adam7 PNGs are rejected with a clear error — convert them first.