Back to engineering

Engineering

Protecting photographers' images on the web: what actually helps

The Bookatu engineering team6 min read

Right-click blockers and CSS overlays are theatre. Here is the pipeline we use in the Bookatu photographer galleries: baked-in pixel watermarks via canvas, a hard downscale to 1080px, EXIF stripping, and serving the full-resolution file only through an ownership-checked route.

The short version

  • Client-side tricks like disabled right-click and transparent overlays do nothing. The full image already sits in the browser cache and the network tab.
  • Protection has to happen on the pixels you ship, not the page you ship them on.
  • Our preview pipeline does four things: bake a watermark into the pixels, downscale hard to 1080px, strip EXIF by re-encoding, and never expose the original URL.
  • The full-resolution file is served only through a route that checks ownership first.
  • In the Bookatu photographer vertical, clients only ever see the watermarked 1080px preview. The original unlocks after the included and paid photos are settled.

A photographer's whole business is the image. So the most common request we get on the Bookatu photographer galleries is simple: stop people taking my work for free. The honest engineering answer is that you cannot make a public image impossible to copy. You can make the copy worthless, and you can keep the valuable version off the wire entirely. Those are very different problems from the one most tutorials try to solve.

Why the CSS tricks are theatre

Most image-protection guides reach for the page, not the file. They disable the right-click menu, lay a transparent div over the photo, or set the image as a CSS background. None of this protects anything. Any image a browser renders has already been fully downloaded to display it. As one widely cited write-up on the subject puts it, the original is sitting in the browser cache, in application storage, and in the network log, all viewable regardless of what the page does.

  • Disabled right-click: the file is one keystroke away in the network tab, or just open the image in a new tab.
  • Transparent overlay div: the real <img> underneath is untouched and saveable from devtools.
  • CSS background-image: the URL is in the stylesheet and the request log.
  • Canvas with right-click off: canvas.toDataURL() hands the raw pixels back as base64.
If the browser can show the pixels, the visitor already has the pixels. The only question you control is which pixels you sent.

What actually helps

The shift is from hiding the file to changing what gets served. We never send the high-resolution original to the gallery view. We generate a derivative that is safe to be copied: smaller, marked, and stripped of metadata. The original stays in private storage and leaves the building only through a route that has checked the request first. Four steps, in order.

Step one: bake the watermark into the pixels

A watermark is only protection if it survives a save. An overlay element does not survive anything. So we draw the original onto a canvas and paint the mark directly into the bitmap, then read the result back out. MDN documents the two methods this leans on: drawImage() to composite the source, and toBlob() (or convertToBlob() in a worker) to emit the encoded result. Because the mark is now part of the pixel data, it is in every copy, every screenshot, and every re-upload.

javascript
// Runs in a Worker via OffscreenCanvas.
// 1) decode  2) downscale  3) bake mark  4) re-encode to JPEG.
async function makePreview(source, mark) {
  const MAX = 1080;
  const scale = Math.min(1, MAX / Math.max(source.width, source.height));
  const w = Math.round(source.width * scale);
  const h = Math.round(source.height * scale);

  const canvas = new OffscreenCanvas(w, h);
  const ctx = canvas.getContext("2d");
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = "high";
  ctx.drawImage(source, 0, 0, w, h);

  // Bake the mark into the bitmap. This is not an overlay.
  ctx.globalAlpha = 0.35;
  ctx.font = `${Math.round(h / 18)}px sans-serif`;
  ctx.fillStyle = "#ffffff";
  ctx.textAlign = "center";
  ctx.translate(w / 2, h / 2);
  ctx.rotate(-Math.PI / 9);
  ctx.fillText(mark, 0, 0);

  // A fresh encode from raw canvas pixels carries no EXIF.
  return canvas.convertToBlob({ type: "image/jpeg", quality: 0.82 });
}

Step two: downscale hard to 1080px

The watermark discourages theft. The downscale makes the stolen copy commercially useless. We cap the longest edge at 1080px before anything reaches the client. A 1080px JPEG looks great on a phone and on a gallery grid, but it will not print at A3 and it will not pass for the original on a stock site. The full-frame file a client actually paid for is never in the preview path. Note in the sketch that drawImage scales during the composite, so the downscale and the mark happen in one pass. MDN's drawImage reference covers the destination width and height arguments that do the scaling.

Step three: strip EXIF by re-encoding

Camera files carry EXIF metadata, and that is a privacy problem, not just a tidiness one. A photo can embed GPS coordinates accurate to a few metres, plus the camera serial number and timestamps. OWASP's ASVS project tracks this directly as a control: scrub EXIF tags such as GPS from media before it is stored or served. The OWASP ZAP scanner even ships an alert for images that expose location or privacy data. The good news is that our pipeline strips EXIF for free. When you decode an image and re-encode fresh pixels off a canvas, the new JPEG is built from the bitmap alone. The original metadata block is simply not part of the output.

  • GPS coordinates: a shoot at a client's home should not leak the client's address.
  • Camera body serial number: stable for the life of the device and traceable.
  • Timestamps and lens data: small leaks that add up across a public gallery.

Step four: gate the full-res file behind ownership

The original lives in private storage with no public URL. It is reachable only through a route that authenticates the request and confirms the requester is allowed to have it. In Bookatu, gallery access is a passwordless magic link, and a link is scoped to one client and one gallery. The route checks that the link is valid, that the photo belongs to that client's gallery, and that the photo has been settled, meaning it falls inside the free included count or has been paid for. Only then do we stream the file.

javascript
// Full-res route. The original is never a public asset.
export async function GET(req, { params }) {
  const session = await resolveMagicLink(req); // long-lived passwordless link
  if (!session) return new Response("Forbidden", { status: 403 });

  const photo = await getPhoto(params.id);
  const ownsGallery = photo.galleryClientId === session.clientId;
  const settled = await isPhotoSettled(session.clientId, photo); // included or paid

  if (!ownsGallery || !settled) {
    return new Response("Forbidden", { status: 403 });
  }

  // Stream from private storage. The key is never exposed to the client.
  return streamPrivateObject(photo.originalKey);
}

How this fits the Bookatu photographer gallery

Putting it together: a photographer uploads high-resolution originals, and the platform auto-generates the low-resolution, baked-in-watermark, EXIF-stripped preview described above. Clients open a private gallery from a long-lived magic link and see only those previews. They pick favourites, get an included free count, and pay for any extra photos. The full-resolution set unlocks only once the included and paid photos are settled, which is exactly what the ownership route enforces. Payments go straight to the photographer's own bank through Stripe Connect at 0% booking commission, and storage is metered with buyable extra packs on the studio's subscription.

None of this stops a determined person from screenshotting a 1080px preview. That is fine. The screenshot is exactly what we were willing to give away: small, marked, and worth nothing as a deliverable. The asset that pays the photographer's rent never left private storage without a check. Spend your effort on the file you send, not on fighting the browser that has to render it.

Sources

  • MDN, HTMLCanvasElement: toBlob() method: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob
  • MDN, CanvasRenderingContext2D: drawImage() method: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
  • OWASP ASVS, Add a control to scrub EXIF tags (e.g. GPS) from uploaded media: https://github.com/OWASP/ASVS/issues/965
  • OWASP ZAP, Image Exposes Location or Privacy Data (alert 10103): https://www.zaproxy.org/docs/alerts/10103/
  • Patrick Wied, How to protect your images on the web: https://www.patrick-wied.at/blog/image-protection-on-the-web
image-protectioncanvaswatermarkingexifphotographer-galleriessecurityengineering

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs