<?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: Akshay Sharma</title>
    <description>The latest articles on DEV Community by Akshay Sharma (@axaysharma).</description>
    <link>https://dev.to/axaysharma</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3877825%2F91698d38-c62d-4cdc-bda3-a9f446db6390.png</url>
      <title>DEV Community: Akshay Sharma</title>
      <link>https://dev.to/axaysharma</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/axaysharma"/>
    <language>en</language>
    <item>
      <title>I built a hiring platform where candidates never apply - here's how the matching works</title>
      <dc:creator>Akshay Sharma</dc:creator>
      <pubDate>Sat, 05 Sep 2026 15:45:59 +0000</pubDate>
      <link>https://dev.to/axaysharma/i-built-a-hiring-platform-where-candidates-never-apply-heres-how-the-matching-works-11ld</link>
      <guid>https://dev.to/axaysharma/i-built-a-hiring-platform-where-candidates-never-apply-heres-how-the-matching-works-11ld</guid>
      <description>&lt;h2&gt;
  
  
  The problem I was trying to solve
&lt;/h2&gt;

&lt;p&gt;Candidates send hundreds of applications. &lt;br&gt;
Companies receive thousands of resumes. &lt;br&gt;
Most candidates never hear back.&lt;/p&gt;

&lt;p&gt;Both sides exhausted. Most of the effort wasted.&lt;/p&gt;

&lt;p&gt;The insight that changed my thinking: senior engineers don't apply to jobs. They get headhunted. A recruiter finds them, reaches out, and they evaluate the opportunity on their terms.&lt;/p&gt;

&lt;p&gt;Why is that only available to senior people?&lt;/p&gt;

&lt;p&gt;It shouldn't be.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;Wrkmark Jobs — a hiring platform where candidates never apply.&lt;/p&gt;

&lt;p&gt;Here's how it works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Candidates create one profile&lt;/li&gt;
&lt;li&gt;Algorithm scores them against active roles&lt;/li&gt;
&lt;li&gt;Companies see their top 15 ranked matches&lt;/li&gt;
&lt;li&gt;Companies reach out. Candidates choose to respond.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No applications. No cover letters. No ghosting.&lt;/p&gt;
&lt;h2&gt;
  
  
  How the matching algorithm works
&lt;/h2&gt;

&lt;p&gt;This is the part I want to talk about technically.&lt;/p&gt;

&lt;p&gt;The algorithm scores each candidate against each job across four dimensions:&lt;/p&gt;
&lt;h3&gt;
  
  
  Skills — 40% of score
&lt;/h3&gt;

&lt;p&gt;Simple exact match (case-insensitive) with a synonym map for common variations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SKILL_SYNONYMS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ruby on rails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ror&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ruby-on-rails&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kubernetes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;k8s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kube&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgresql&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;psql&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;javascript&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ecmascript&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;// 60+ mappings&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A candidate with "RoR" on their profile matches a job requiring "Ruby on Rails". Simple but surprisingly effective at this scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  Salary — 25% of score
&lt;/h3&gt;

&lt;p&gt;All salaries converted to USD for comparison using live exchange rates (Frankfurter API).&lt;/p&gt;

&lt;p&gt;The logic:&lt;br&gt;
job_max_usd &amp;gt;= candidate_min_usd → score 100&lt;br&gt;
job_max_usd &amp;lt; candidate_min_usd → score 0&lt;/p&gt;

&lt;p&gt;If a company offers $80-120K and a candidate expects $30-50K — that's a great match. The company can easily meet the candidate's &lt;br&gt;
expectation. Score: 100.&lt;/p&gt;

&lt;p&gt;The common mistake is calculating range overlap. Overlap fails in the overqualified-offer case.&lt;/p&gt;
&lt;h3&gt;
  
  
  Experience — 20% of score
&lt;/h3&gt;

&lt;p&gt;Years of experience vs role requirement. &lt;br&gt;
Meeting or exceeding → full score. &lt;br&gt;
Under by 50% → partial score.&lt;/p&gt;
&lt;h3&gt;
  
  
  Timezone — 15% of score
&lt;/h3&gt;

&lt;p&gt;This one I'm most proud of. Most platforms do binary bucket matching:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"US East" or not&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We calculate actual UTC hour overlap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UTC_WINDOWS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;us_east&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;// 9am-5pm EST&lt;/span&gt;
  &lt;span class="na"&gt;europe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;     &lt;span class="c1"&gt;// 9am-5pm CET&lt;/span&gt;
  &lt;span class="na"&gt;apac&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;        &lt;span class="c1"&gt;// 9am-5pm IST/SGT&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getOverlapHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;window1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;window2&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;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;window1&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;window2&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;window1&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;window2&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&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;6+ hours overlap → 100 score&lt;br&gt;
4-5 hours → 80&lt;br&gt;
2-3 hours → 50&lt;br&gt;
1 hour → 25&lt;br&gt;
0 → fail&lt;/p&gt;

&lt;p&gt;A US East job and a European candidate have 2 hours overlap (14:00-16:00 UTC). That scores  50 — workable with async communication. Better than the binary "not US East = zero" approach.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hard filters (before scoring)
&lt;/h3&gt;

&lt;p&gt;Before scoring, candidates must pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Employment type match&lt;/li&gt;
&lt;li&gt;Work arrangement match (remote/hybrid/onsite)&lt;/li&gt;
&lt;li&gt;Visa/sponsorship check (checks work_authorized_countries)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The visa check was interesting. An Indian citizen  applying to an Indian company doesn't need sponsorship even if they've marked "needs_sponsorship: yes" globally. &lt;br&gt;
We check work_authorized_countries first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not ML?
&lt;/h2&gt;

&lt;p&gt;At current scale — a few thousand candidates and hundreds of jobs — pure algorithmic matching is more accurate than ML.&lt;/p&gt;

&lt;p&gt;ML needs large datasets to find patterns. &lt;br&gt;
With small data, it overfits.&lt;/p&gt;

&lt;p&gt;The algorithmic approach is also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Explainable (candidates see exactly why they matched)&lt;/li&gt;
&lt;li&gt;Debuggable (I can trace every score)&lt;/li&gt;
&lt;li&gt;Fast (no model inference, just math)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'll add ML when I have 100K+ matches worth of outcome data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The paid candidate model
&lt;/h2&gt;

&lt;p&gt;Candidates pay $9/year globally (₹99 in India).&lt;/p&gt;

&lt;p&gt;Controversial? Yes. But here's the logic:&lt;/p&gt;

&lt;p&gt;Free platforms have millions of profiles. &lt;br&gt;
Most are fake, inactive, or outdated. &lt;br&gt;
Companies stop responding because signal is lost. &lt;br&gt;
Candidates get ghosted.&lt;/p&gt;

&lt;p&gt;$9/year is a filter. Every profile on Wrkmark &lt;br&gt;
is someone who meant it enough to pay. &lt;br&gt;
Companies know this. They actually respond.&lt;/p&gt;

&lt;p&gt;On free platforms, candidates are the product. &lt;br&gt;
On Wrkmark, they're the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend/Backend&lt;/strong&gt;: Next.js 14 App Router&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database&lt;/strong&gt;: Supabase (PostgreSQL + Auth + Storage)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting&lt;/strong&gt;: Vercel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments&lt;/strong&gt;: Dodo Payments (MoR — handles global 
tax compliance without company registration)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email&lt;/strong&gt;: Resend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resume parsing&lt;/strong&gt;: Gemini 3.6 Flash 
(native PDF, no pdf-parse library)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics&lt;/strong&gt;: Umami (privacy-first)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's live
&lt;/h2&gt;

&lt;p&gt;&lt;a href="//jobs.wrkmark.com"&gt;Wrkmark Jobs&lt;/a&gt; — open beta.&lt;/p&gt;

&lt;p&gt;Looking for candidates: engineering, product, data, design, DevOps. India, US, UK, EU, Southeast Asia. Remote-first.&lt;/p&gt;

&lt;p&gt;Candidate membership: ₹99/year India, $9/year global.&lt;br&gt;
Company job post: ₹5,000 India, $149 global.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about the algorithm, the stack, or the two-sided marketplace problem.&lt;/p&gt;

</description>
      <category>career</category>
      <category>jobsearch</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>How I built a file-sharing tool where even I can't read your files (zero-knowledge architecture in Next.js)</title>
      <dc:creator>Akshay Sharma</dc:creator>
      <pubDate>Tue, 14 Apr 2026 05:53:34 +0000</pubDate>
      <link>https://dev.to/axaysharma/how-i-built-a-file-sharing-tool-where-even-i-cant-read-your-files-zero-knowledge-architecture-in-5h9a</link>
      <guid>https://dev.to/axaysharma/how-i-built-a-file-sharing-tool-where-even-i-cant-read-your-files-zero-knowledge-architecture-in-5h9a</guid>
      <description>&lt;p&gt;There's a moment most developers have experienced.&lt;/p&gt;

&lt;p&gt;You need to send a sensitive file - a private screenshot, a contract draft, a credential - and your options are: email it (it lives on both servers forever), WhatsApp it (metadata collected, file cached), or throw it on Google Drive (permanently indexed, permission revoke does nothing to cached versions).&lt;/p&gt;

&lt;p&gt;None of these give you control. The file outlives the intention.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;BurnShot&lt;/strong&gt; (&lt;a href="//burnshot.app"&gt;burnshot.app&lt;/a&gt;) - a zero-knowledge, ephemeral sharing tool where files cryptographically erase themselves after a set view count or timer. Here's the architecture, the decisions, and the parts I got wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core problem with 'disappearing' file tools
&lt;/h2&gt;

&lt;p&gt;Most tools that claim to offer disappearing or expiring files do one of two things: they either mark a file as 'deleted' in their database (but keep the bytes in storage), or they rely on the client to stop rendering the file without actually deleting it server-side.&lt;/p&gt;

&lt;p&gt;Neither is deletion. Both are theater.&lt;/p&gt;

&lt;p&gt;Real ephemeral sharing requires three things happening simultaneously:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The file must be encrypted before it touches any server&lt;/li&gt;
&lt;li&gt;The server must never possess the decryption key&lt;/li&gt;
&lt;li&gt;Deletion must be synchronous — not a background job&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The encryption model: keys in the URL fragment
&lt;/h2&gt;

&lt;p&gt;When you upload a file on BurnShot, &lt;em&gt;AES-GCM&lt;/em&gt; encryption runs entirely in the browser using the Web Crypto API before the file is transmitted. The encrypted blob is what gets stored in Supabase Storage.&lt;/p&gt;

&lt;p&gt;The decryption key is embedded in the shareable URL after the # (hash fragment). This is the critical part: URL fragments are never sent to the server in HTTP requests. They exist only in the browser. My server has never seen your key.&lt;/p&gt;

&lt;p&gt;`// Simplified client-side encryption flow&lt;br&gt;
const key = await crypto.subtle.generateKey(&lt;br&gt;
  { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']&lt;br&gt;
);&lt;br&gt;
const encrypted = await crypto.subtle.encrypt(&lt;br&gt;
  { name: 'AES-GCM', iv }, key, fileBuffer&lt;br&gt;
);&lt;br&gt;
const exportedKey = await crypto.subtle.exportKey('raw', key);&lt;br&gt;
const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(exportedKey)));&lt;/p&gt;

&lt;p&gt;// Key goes into the URL fragment — never sent to server&lt;br&gt;
const shareLink = &lt;code&gt;https://burnshot.app/view/${fileId}#${keyBase64}&lt;/code&gt;;`&lt;/p&gt;

&lt;p&gt;This means if you subpoena my server, you get encrypted blobs. The keys don't exist there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auto-detonation: synchronous, not scheduled
&lt;/h2&gt;

&lt;p&gt;Every file payload has a view_limit and a TTL stored in Postgres. Here's what happens on every access:&lt;br&gt;
`-- Supabase RPC called on every view&lt;br&gt;
UPDATE payloads&lt;br&gt;
SET view_count = view_count + 1&lt;br&gt;
WHERE id = $1&lt;br&gt;
RETURNING view_count, view_limit, expires_at;&lt;/p&gt;

&lt;p&gt;-- If threshold hit: immediate purge&lt;br&gt;
SELECT storage.delete('payloads', file_ref);&lt;br&gt;
DELETE FROM payloads WHERE id = $1;`&lt;/p&gt;

&lt;p&gt;The deletion is atomic within the RPC. The file is gone before the HTTP response returns. There is no window where a race condition leaves the file accessible after its limit is hit — this was v1's critical bug, and I'll explain that shortly.&lt;/p&gt;

&lt;h2&gt;
  
  
  OTP email verification without storing emails (you can share files without email verification also)
&lt;/h2&gt;

&lt;p&gt;If a sender wants to restrict access to a specific recipient, they add the recipient's email. Here's the privacy-preserving part: that email is SHA-256 hashed on the sender's device before it ever hits the API.&lt;/p&gt;

&lt;p&gt;My database stores hash(email), never the plaintext. When the recipient enters their email to access the file, it gets hashed client-side and compared. I cannot tell you who the intended recipient was.&lt;/p&gt;

&lt;p&gt;Ofcourse third party service for email - Resend has to know the plaintext email to deliver the message, and their logs will show the email body (the OTP). On Resend also I have set very short retention period which means this data gets deleted within few days.&lt;/p&gt;

&lt;p&gt;But if a hacker breaches Supabase, they get encrypted blobs and hashed emails. They cannot read the files, and they don't know who the users are.&lt;br&gt;
If a hacker breaches Resend, they get a list of email addresses and 6-digit numbers. But Resend has absolutely no access to the files, the decryption keys, or the database links.&lt;/p&gt;

&lt;p&gt;Neither system holds the complete puzzle. Thus, at any given point of time through strict separation of concerns, your data remains impenetrable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-download canvas rendering
&lt;/h2&gt;

&lt;p&gt;Files render inside a canvas element rather than native img or embed tags. This disables right-click save, browser download shortcuts, and most automated scrapers. It doesn't prevent screenshots — nothing on the web can. But it removes one-click exfiltration, which is the threat model for most use cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong in v1
&lt;/h2&gt;

&lt;p&gt;Version 1 ran file deletion in a background cron job every 5 minutes. The first comment on my Hacker News post was: 'Web based! receiver can take a screenshot very easily.'&lt;/p&gt;

&lt;p&gt;They were right about the deletion window - there was a 5-minute gap after the final view where the file was still technically accessible. I rewrote the deletion logic to be synchronous within the RPC that same night. The screenshot comment was also valid — I added canvas rendering after that.&lt;/p&gt;

&lt;p&gt;The second thing I got wrong: I launched with images only. Within weeks, the most requested feature was PDF support. Contracts, NDAs, payslips - the real sensitive sharing is documents, not photos. I added PDF support in v1.2 and it's now the majority of uploads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 14 + TypeScript - App Router, server actions for API layer&lt;/li&gt;
&lt;li&gt;Supabase — Postgres for metadata, Storage for encrypted blobs, Edge Functions for RPC&lt;/li&gt;
&lt;li&gt;Vercel — Edge deployment, sub-100ms global response&lt;/li&gt;
&lt;li&gt;Web Crypto API — all encryption/decryption in the browser&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Watermarking: burning recipient identity into canvas renders for traceability&lt;/li&gt;
&lt;li&gt;Audit trail for Enterprise without breaking zero-knowledge model&lt;/li&gt;
&lt;li&gt;CLI tool for developers integrating ephemeral sharing into pipelines
The tool is free at &lt;a href="//burnshot.app"&gt;burnshot.app&lt;/a&gt;. Enterprise dedicated instances (isolated DB, custom domain) are available at $100/month for firms with compliance requirements.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to go deep on any part of the architecture in the comments - the Web Crypto API has some gotchas around key serialization that took me a while to work through.&lt;/p&gt;

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