<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: whitetirocket</title>
    <description>The latest articles on DEV Community by whitetirocket (@whitetirocket).</description>
    <link>https://dev.to/whitetirocket</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3900468%2Fc5aec947-7d12-4b5b-8307-eca83bab9a36.png</url>
      <title>DEV Community: whitetirocket</title>
      <link>https://dev.to/whitetirocket</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/whitetirocket"/>
    <language>en</language>
    <item>
      <title>Building a privacy-first passport photo tool that runs entirely in the browser</title>
      <dc:creator>whitetirocket</dc:creator>
      <pubDate>Tue, 28 Apr 2026 04:03:10 +0000</pubDate>
      <link>https://dev.to/whitetirocket/building-a-privacy-first-passport-photo-tool-that-runs-entirely-in-the-browser-i94</link>
      <guid>https://dev.to/whitetirocket/building-a-privacy-first-passport-photo-tool-that-runs-entirely-in-the-browser-i94</guid>
      <description>&lt;p&gt;I launched IDPhotoSnap on Product Hunt today. It's a free passport, visa, and ID photo maker for 85+ countries. Here's the technical writeup of why it runs 100% in the browser, what that bought me, and where the tradeoffs were.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why client-side
&lt;/h2&gt;

&lt;p&gt;The straightforward way to build this product would have been:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User uploads photo → server&lt;/li&gt;
&lt;li&gt;Server runs image processing (crop, resize, background)&lt;/li&gt;
&lt;li&gt;Server sends back result&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the architecture every paid competitor uses. It also costs money to run, requires user accounts to manage abuse, and creates a privacy concern: somewhere on a server is a database of passport photos.&lt;/p&gt;

&lt;p&gt;Client-side processing flips all three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; $0 compute. The user's phone does the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Abuse model:&lt;/strong&gt; there's nothing to abuse. There's no server to overload, no API to rate-limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy:&lt;/strong&gt; the photo never leaves the device. This is verifiable — open DevTools and watch the network tab.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's actually running in the browser
&lt;/h2&gt;

&lt;p&gt;The core processing pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processPhoto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;countrySpec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;faceBox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;detectFace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cropBox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeCrop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;faceBox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;countrySpec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;renderCrop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cropBox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;countrySpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three steps that matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Face detection&lt;/strong&gt; uses the FaceDetector API where available (Chrome on Android, recent Safari) and falls back to a small TensorFlow.js model on browsers that don't support it. The fallback adds about 4MB to the initial load but only loads on demand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Crop computation&lt;/strong&gt; is country-specific. Each country has documented requirements like "face must occupy 70-80% of the frame, vertically centered, eyes at 60% from the bottom." These are encoded as JSON specs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dimensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"width_mm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;51&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"height_mm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;51&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"dpi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"face"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"min_height_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"max_height_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;69&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"vertical_center_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;56&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"background"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#FFFFFF"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"head_position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"centered"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting these specs right was the actual hard work. Some governments publish them well. Most don't. I ended up cross-referencing consulate PDFs in the local language for about half the countries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Canvas rendering&lt;/strong&gt; is just &lt;code&gt;drawImage&lt;/code&gt; with the computed bounding box, then &lt;code&gt;toDataURL&lt;/code&gt; for export. No magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background removal
&lt;/h2&gt;

&lt;p&gt;This was the part where I almost gave up on client-side. Background removal historically meant a U-Net or similar segmentation model — too heavy for the browser.&lt;/p&gt;

&lt;p&gt;The answer was the &lt;a href="https://developers.google.com/mediapipe" rel="noopener noreferrer"&gt;MediaPipe Selfie Segmentation&lt;/a&gt; model. It's about 256KB, runs at 30fps on a mid-range phone, and produces a soft alpha mask good enough for passport photo backgrounds. After segmentation, I composite over a white canvas. Done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I lost by going client-side
&lt;/h2&gt;

&lt;p&gt;Three real tradeoffs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No analytics on uploaded photos.&lt;/strong&gt; Useful for debugging but obviously not viable here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Initial load is heavier.&lt;/strong&gt; First visit fetches face detection fallback + segmentation model. Total: ~5MB. After cache, instant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No batch processing.&lt;/strong&gt; Can't queue 1000 photos through. But this is a passport photo tool — one photo at a time is the use case.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I gained
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hosting cost is $0.&lt;/strong&gt; Just a static site on Vercel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No GDPR exposure.&lt;/strong&gt; No user data is collected because none is transmitted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Genuinely free.&lt;/strong&gt; Because there's no compute cost, the product can stay free forever without ads, subscriptions, or tier-locking.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  On Product Hunt
&lt;/h2&gt;

&lt;p&gt;If you want to see the result, the launch is here: &lt;a href="https://www.producthunt.com/products/idphotosnap" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/idphotosnap&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The site itself is at &lt;a href="https://idphotosnap.com" rel="noopener noreferrer"&gt;https://idphotosnap.com&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: How does this make money?&lt;/strong&gt;&lt;br&gt;
It doesn't yet. If traffic grows, I'll add unobtrusive ads. No subscription tier planned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why not WebAssembly for face detection?&lt;/strong&gt;&lt;br&gt;
The FaceDetector API is fast enough on modern phones and the TF.js fallback handles older browsers. WASM would be a reasonable optimization later but isn't blocking anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What if I want to verify the photo doesn't leave my device?&lt;/strong&gt;&lt;br&gt;
Open DevTools → Network tab → upload a photo. You'll see no requests carrying image data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can I self-host?&lt;/strong&gt;&lt;br&gt;
Not open source yet. Possibly in the future after the codebase stabilizes.&lt;/p&gt;

&lt;p&gt;Feedback welcome. The hardest thing right now isn't the code, it's getting the country specs right — if you've used the tool for a country and the result was rejected, I want to know.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
