<?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>Forem: FileShot</title>
    <description>The latest articles on Forem by FileShot (@fileshot_9818357dbe6cc693).</description>
    <link>https://forem.com/fileshot_9818357dbe6cc693</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%2F3735555%2Fcdd869ba-6cac-4f2d-a305-2c33fcf033d4.png</url>
      <title>Forem: FileShot</title>
      <link>https://forem.com/fileshot_9818357dbe6cc693</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/fileshot_9818357dbe6cc693"/>
    <language>en</language>
    <item>
      <title>What actually goes into a production-ready SaaS boilerplate (hint: it's not just auth)</title>
      <dc:creator>FileShot</dc:creator>
      <pubDate>Sun, 08 Mar 2026 14:00:03 +0000</pubDate>
      <link>https://forem.com/fileshot_9818357dbe6cc693/what-actually-goes-into-a-production-ready-saas-boilerplate-hint-its-not-just-auth-2ocd</link>
      <guid>https://forem.com/fileshot_9818357dbe6cc693/what-actually-goes-into-a-production-ready-saas-boilerplate-hint-its-not-just-auth-2ocd</guid>
      <description>&lt;p&gt;Every developer who's shipped a SaaS product has had the same moment: you stare at a blank repo and think "I'll just wire up auth real quick." Three weeks later you're still fighting OAuth redirect URIs, forgetting to hash passwords correctly, and trying to remember if you added rate limiting to the login endpoint.&lt;/p&gt;

&lt;p&gt;Auth is the obvious starting point. But it's not the hard part.&lt;/p&gt;

&lt;h2&gt;
  
  
  What people miss when they think about "production-ready"
&lt;/h2&gt;

&lt;p&gt;Here's a non-exhaustive list of what a real production SaaS needs beyond auth:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payments that don't break&lt;/strong&gt;&lt;br&gt;
Stripe webhooks are notoriously tricky. You need idempotency keys, signature verification on every incoming webhook, retry handling, and logic for subscription states: &lt;code&gt;trialing&lt;/code&gt;, &lt;code&gt;active&lt;/code&gt;, &lt;code&gt;past_due&lt;/code&gt;, &lt;code&gt;canceled&lt;/code&gt;. Miss any of these and you'll have users who upgraded but still see the free tier — or get double-charged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API rate limiting&lt;/strong&gt;&lt;br&gt;
Every public endpoint needs it. Not just "limit to 100 req/minute globally" — per-user, per-endpoint rate limiting with proper 429 responses and &lt;code&gt;Retry-After&lt;/code&gt; headers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email infrastructure&lt;/strong&gt;&lt;br&gt;
Transactional emails (password reset, invoice receipt, account confirmation) need to actually deliver. That means a proper SMTP provider, SPF/DKIM records, and templates that render in Outlook circa 2019.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proper session management&lt;/strong&gt;&lt;br&gt;
JWTs with sensible expiry, refresh token rotation, revocation on logout. If you're storing sessions in a cookie, make sure it's &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, and &lt;code&gt;SameSite=Strict&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API keys for programmatic access&lt;/strong&gt;&lt;br&gt;
If you're building anything developer-facing, users will want API keys. That's a whole sub-system: key generation (don't store raw keys — hash them), scopes/permissions, revocation, usage tracking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two-factor authentication&lt;/strong&gt;&lt;br&gt;
TOTP-based 2FA (Google Authenticator, Authy) with proper backup codes. Not optional for anything handling real user data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database migrations&lt;/strong&gt;&lt;br&gt;
Not "I'll just alter the table in prod" — a versioned, idempotent migration system that can run on deploy without downtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker + deployment config&lt;/strong&gt;&lt;br&gt;
An actual Dockerfile, not just "it works on my machine." Environment variable management, health check endpoints, graceful shutdown handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard to get right the first time
&lt;/h2&gt;

&lt;p&gt;Each of these subsystems has its own set of edge cases, and they interact with each other. Your rate limiter needs to know who the user is (auth integration). Your Stripe webhooks need to update your database (migration dependency). Your 2FA backup codes need to be hashed (same as API keys).&lt;/p&gt;

&lt;p&gt;Getting all of this right from scratch takes weeks and usually results in at least a few security mistakes along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I've been building
&lt;/h2&gt;

&lt;p&gt;I got tired of rebuilding the same scaffolding for every project, so I built &lt;a href="https://diggabyte.com" rel="noopener noreferrer"&gt;DiggaByte Labs&lt;/a&gt; — a SaaS boilerplate marketplace where you pick exactly the stack modules you need and download a production-ready codebase built around those choices.&lt;/p&gt;

&lt;p&gt;The idea is: instead of a "starter template" that's just an opinionated file structure, you get code that's actually wired together. Stripe webhooks already talk to your database. Rate limiting is already in the middleware chain. The 2FA flow already works end-to-end.&lt;/p&gt;

&lt;p&gt;The current stack selections include: PostgreSQL + Prisma, JWT auth, Google OAuth, GitHub OAuth, Stripe subscriptions, shadcn/ui, rate limiting, API keys, 2FA, and Docker deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I use before calling anything "production-ready"
&lt;/h2&gt;

&lt;p&gt;Before shipping any SaaS, I go through this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] All passwords bcrypt-hashed (cost factor 12+)&lt;/li&gt;
&lt;li&gt;[ ] JWT secret is 256-bit random, not a dictionary word&lt;/li&gt;
&lt;li&gt;[ ] Refresh tokens rotate on use, invalidate on logout&lt;/li&gt;
&lt;li&gt;[ ] Stripe webhook signature verified on every event&lt;/li&gt;
&lt;li&gt;[ ] Rate limiting on auth endpoints (login, password reset, 2FA)&lt;/li&gt;
&lt;li&gt;[ ] API keys hashed in DB (store only the last 4 chars for display)&lt;/li&gt;
&lt;li&gt;[ ] 2FA backup codes are one-time-use and hashed&lt;/li&gt;
&lt;li&gt;[ ] SQL queries use parameterized statements (no string concatenation)&lt;/li&gt;
&lt;li&gt;[ ] Environment variables never logged&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;HttpOnly&lt;/code&gt; + &lt;code&gt;Secure&lt;/code&gt; + &lt;code&gt;SameSite&lt;/code&gt; on all auth cookies&lt;/li&gt;
&lt;li&gt;[ ] Health check endpoint at &lt;code&gt;/health&lt;/code&gt; returns 200 with no sensitive info&lt;/li&gt;
&lt;li&gt;[ ] Graceful shutdown: drain connections before &lt;code&gt;process.exit&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those are unchecked in your codebase, that's the real work.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about any of these in the comments — particularly the Stripe webhook setup and the API key hashing pattern, which I see done incorrectly in the wild constantly.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>startup</category>
    </item>
    <item>
      <title>Why I stopped trusting cloud storage with my client files</title>
      <dc:creator>FileShot</dc:creator>
      <pubDate>Tue, 03 Mar 2026 21:41:28 +0000</pubDate>
      <link>https://forem.com/fileshot_9818357dbe6cc693/why-i-stopped-trusting-cloud-storage-with-my-client-files-2031</link>
      <guid>https://forem.com/fileshot_9818357dbe6cc693/why-i-stopped-trusting-cloud-storage-with-my-client-files-2031</guid>
      <description>&lt;p&gt;I work with clients who send me contracts, legal documents, and financial statements. For years I stored them in Google Drive without thinking twice. Then I read their Terms of Service carefully.&lt;/p&gt;

&lt;p&gt;"Google's automated systems analyze your content to provide you with personally relevant product features." That's in Drive's ToS. Not hidden — just not something most people actually read.&lt;/p&gt;

&lt;p&gt;I'm not claiming Google is doing anything malicious with my client files. But the technical fact is: Google has the encryption keys for every file in Drive. They &lt;em&gt;can&lt;/em&gt; read them. Whether they choose to is a policy question, not a technical one. And policies can change, get subpoenaed, or get breached.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with "encrypted at rest"
&lt;/h2&gt;

&lt;p&gt;Most cloud providers advertise encryption. It sounds reassuring. But "encrypted at rest" just means the file is scrambled on disk — and the provider holds the key. It's the digital equivalent of giving your landlord a copy of your front door key and then calling it a locked apartment.&lt;/p&gt;

&lt;p&gt;True privacy requires the &lt;em&gt;provider&lt;/em&gt; to not have the key. Your device encrypts the data before it ever leaves. The provider receives only ciphertext — random bytes they can't read regardless of whether they want to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I switched to
&lt;/h2&gt;

&lt;p&gt;For one-off client file delivery — sending a contract draft, a final invoice, a signed document — I switched to &lt;a href="https://fileshot.io" rel="noopener noreferrer"&gt;FileShot.io&lt;/a&gt;. It's open source, free to start, and works entirely in the browser. You drag in a file, it encrypts it locally using AES-256-GCM (the Web Crypto API handles this before the upload starts), and you get back a link.&lt;/p&gt;

&lt;p&gt;The decryption key is in the URL fragment — the part after #. Browsers never send URL fragments to servers. So the FileShot server receives and stores encrypted bytes and has no idea what the file contains or even how large the plaintext is. When the recipient opens the link, their browser downloads the ciphertext and decrypts it locally.&lt;/p&gt;

&lt;p&gt;No account needed. No storage limits to worry about for one-off transfers. The file is available for however long you set the expiry.&lt;/p&gt;

&lt;h2&gt;
  
  
  For ongoing storage I use Proton Drive
&lt;/h2&gt;

&lt;p&gt;FileShot isn't a replacement for cloud storage — it's for point-to-point sharing. For documents I need to keep long-term, I moved to Proton Drive. End-to-end encrypted, provider can't read your files, and it has a proper folder structure and sync client.&lt;/p&gt;

&lt;p&gt;Tresorit is the enterprise alternative if you need compliance features and auditing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point isn't paranoia
&lt;/h2&gt;

&lt;p&gt;I'm not writing this because I think Google is evil or that your files are being actively read. The point is architectural: if you care about client confidentiality, you should know whether your file storage provider &lt;em&gt;technically can&lt;/em&gt; access your files, regardless of whether they will.&lt;/p&gt;

&lt;p&gt;The answer for Drive, Dropbox, and OneDrive is: yes, they technically can. For Proton Drive, Tresorit, and FileShot.io: no, they technically can't — by design.&lt;/p&gt;

&lt;p&gt;That's a meaningful distinction, especially if you're in law, finance, healthcare, or any field where confidentiality isn't optional.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;FileShot.io is MIT-licensed and the source is on GitHub if you want to verify the encryption implementation yourself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloud</category>
      <category>google</category>
      <category>privacy</category>
      <category>security</category>
    </item>
    <item>
      <title>Why You Should Never Trust the Server With Your Encryption Keys</title>
      <dc:creator>FileShot</dc:creator>
      <pubDate>Tue, 03 Mar 2026 18:49:43 +0000</pubDate>
      <link>https://forem.com/fileshot_9818357dbe6cc693/why-you-should-never-trust-the-server-with-your-encryption-keys-297n</link>
      <guid>https://forem.com/fileshot_9818357dbe6cc693/why-you-should-never-trust-the-server-with-your-encryption-keys-297n</guid>
      <description>&lt;p&gt;When you upload a file to most cloud services, here's what actually happens: your file travels to their servers in plaintext (or gets decrypted on their end), and they hold the encryption keys. That means they can read your data. Whether they do is a different question, but architecturally, you've transferred trust.&lt;/p&gt;

&lt;p&gt;There's a better model. It's called zero-knowledge encryption, and it flips the architecture entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Server-Side Encryption
&lt;/h2&gt;

&lt;p&gt;Services like Dropbox, Google Drive, and iCloud offer "encryption at rest." This sounds safe, but it means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your file is encrypted after reaching their servers&lt;/li&gt;
&lt;li&gt;They hold the keys&lt;/li&gt;
&lt;li&gt;Any employee, court order, or data breach can expose your files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Encryption at rest" is really just protection against someone stealing their hard drives. It doesn't protect you from the service itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Zero-Knowledge Alternative
&lt;/h2&gt;

&lt;p&gt;Zero-knowledge means the server mathematically cannot read your data.&lt;/p&gt;

&lt;p&gt;Here's the pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client side:
  1. Generate a random 256-bit AES key
  2. Encrypt the file with AES-256-GCM
  3. Upload the ciphertext to the server
  4. Embed the key in URL fragment: https://yourservice.com/file#KEY_HERE

Server side:
  - Receives: encrypted blob only
  - Stores: encrypted blob only
  - Key: never seen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The URL fragment (the # part) is defined in RFC 3986 as a client-side identifier. Browsers intentionally do not send fragment identifiers in HTTP requests. The key lives in the browser and never touches your server's logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AES-256-GCM Specifically
&lt;/h2&gt;

&lt;p&gt;GCM (Galois/Counter Mode) is an authenticated encryption mode, meaning it provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Confidentiality&lt;/strong&gt;: data is encrypted&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity&lt;/strong&gt;: any tampering is detectable (the auth tag fails)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt;: you know the data hasn't been modified&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With AES-GCM, the decryption will fail immediately if the ciphertext was altered. With unauthenticated encryption modes like AES-CBC, you wouldn't know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing It With the Web Crypto API
&lt;/h2&gt;

&lt;p&gt;The browser's built-in SubtleCrypto API is all you need:&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="c1"&gt;// Generate key&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="kc"&gt;true&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;encrypt&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;decrypt&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="c1"&gt;// Generate IV (must be unique per encryption operation)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Encrypt&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encrypted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;fileArrayBuffer&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Export key for the URL fragment&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exportedKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exportKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&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;keyHex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exportedKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&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="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: Always generate a fresh IV for every encryption. Reusing an IV with the same key completely breaks AES-GCM security.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trust Model Difference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Server-Side Encryption&lt;/th&gt;
&lt;th&gt;Zero-Knowledge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who holds keys?&lt;/td&gt;
&lt;td&gt;Service provider&lt;/td&gt;
&lt;td&gt;Only you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can service read files?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Breach exposes files?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Encrypted blobs only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subpoena risk&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Real-World Example
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://fileshot.io" rel="noopener noreferrer"&gt;FileShot.io&lt;/a&gt; implements this architecture for file sharing. When you upload:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your file is encrypted in browser memory using AES-256-GCM&lt;/li&gt;
&lt;li&gt;The ciphertext is uploaded&lt;/li&gt;
&lt;li&gt;The key and IV are embedded in the share URL fragment&lt;/li&gt;
&lt;li&gt;The recipient's browser fetches the ciphertext, extracts the key from the fragment, and decrypts locally&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The server's database contains only encrypted blobs. Even with full database access, there's nothing readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;"We encrypt your data" from a service provider means nothing unless you hold the keys. Using a service that implements browser-side encryption means the server's trustworthiness becomes irrelevant.&lt;/p&gt;

&lt;p&gt;The Web Crypto API makes this implementable in pure JavaScript with no external dependencies.&lt;/p&gt;

&lt;p&gt;Trust math, not promises.&lt;/p&gt;

</description>
      <category>securityencryptionprivacy</category>
    </item>
    <item>
      <title>How URL Fragments Solve the Key Distribution Problem in Zero-Knowledge File Sharing</title>
      <dc:creator>FileShot</dc:creator>
      <pubDate>Tue, 03 Mar 2026 18:28:25 +0000</pubDate>
      <link>https://forem.com/fileshot_9818357dbe6cc693/how-url-fragments-solve-the-key-distribution-problem-in-zero-knowledge-file-sharing-40al</link>
      <guid>https://forem.com/fileshot_9818357dbe6cc693/how-url-fragments-solve-the-key-distribution-problem-in-zero-knowledge-file-sharing-40al</guid>
      <description>&lt;p&gt;One of the core challenges in building a zero-knowledge file sharing service is key distribution: how do you give the recipient the decryption key without the server ever seeing it?&lt;/p&gt;

&lt;p&gt;The answer has been embedded in the HTTP spec since 1994. It's called the URL fragment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;In most "encrypted" file sharing services, the workflow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User uploads file&lt;/li&gt;
&lt;li&gt;Service encrypts it server-side (using their own keys)&lt;/li&gt;
&lt;li&gt;Service sends recipient a download link&lt;/li&gt;
&lt;li&gt;Service decrypts the file when the recipient requests it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is encryption at rest. The service can read every file whenever they want. The encryption protects against disk theft, not against the service itself or its government.&lt;/p&gt;

&lt;p&gt;True zero-knowledge means &lt;strong&gt;the server is architecturally prevented from decrypting files&lt;/strong&gt; — not by policy, but by the fact that it never has the key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The URL Fragment Solution
&lt;/h2&gt;

&lt;p&gt;The HTTP/1.1 RFC 2396 specifies:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"A fragment identifier is separated from the rest of a URI by a hash (#) character and contains additional resource information."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And critically: &lt;strong&gt;HTTP clients (browsers) do not include the fragment in requests to servers.&lt;/strong&gt; The fragment lives entirely in the browser.&lt;/p&gt;

&lt;p&gt;This is why anchor links work without a round-trip: when you navigate to &lt;code&gt;page.html#section-3&lt;/code&gt;, the browser fetches &lt;code&gt;page.html&lt;/code&gt; and then scrolls to &lt;code&gt;#section-3&lt;/code&gt; locally, without telling the server which anchor you navigated to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying This to Key Transport
&lt;/h2&gt;

&lt;p&gt;When a file is shared with zero-knowledge encryption, the share URL looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://fileshot.io/d/a1b2c3d4e5#AES_KEY_GOES_HERE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the recipient visits this URL:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Browser sends &lt;code&gt;GET /d/a1b2c3d4e5&lt;/code&gt; to the server — the fragment is stripped&lt;/li&gt;
&lt;li&gt;Server has no idea what &lt;code&gt;AES_KEY_GOES_HERE&lt;/code&gt; is&lt;/li&gt;
&lt;li&gt;Server returns the encrypted ciphertext&lt;/li&gt;
&lt;li&gt;Browser extracts the key from &lt;code&gt;location.hash&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Browser decrypts the file locally using the Web Crypto API&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can verify this behavior in your browser's DevTools. Navigate to any URL with a fragment and look at the Network tab — the request URL sent to the server will never include the &lt;code&gt;#...&lt;/code&gt; portion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Implementation with Web Crypto API
&lt;/h2&gt;

&lt;p&gt;Here's the core of how this works in browser JavaScript:&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="c1"&gt;// Encrypt&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encryptFile&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// extractable&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;encrypt&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;decrypt&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&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;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Export key as URL-safe base64&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exportKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&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;keyB64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawKey&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/=/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;keyFragment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;keyB64&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Decrypt&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decryptFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keyFragment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Re-pad base64&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;padded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;keyFragment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&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;rawKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;padded&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;rawKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="kc"&gt;false&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;decrypt&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plaintext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;ciphertext&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;plaintext&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;The key extracted from &lt;code&gt;location.hash.substring(1)&lt;/code&gt; is passed directly to &lt;code&gt;decryptFile&lt;/code&gt;. The server never sees it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AES-GCM Tag Provides Authentication Too
&lt;/h2&gt;

&lt;p&gt;A detail worth noting: AES-GCM (Galois/Counter Mode) is an authenticated encryption scheme. The 128-bit authentication tag appended to the ciphertext means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the ciphertext is tampered with (even one bit), decryption fails with an integrity error&lt;/li&gt;
&lt;li&gt;The recipient knows the file hasn't been modified in transit&lt;/li&gt;
&lt;li&gt;No separate HMAC is needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why &lt;code&gt;crypto.subtle.decrypt&lt;/code&gt; can throw — it's verifying the tag, not just decrypting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Can Go Wrong
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Shared URLs over insecure channels
&lt;/h3&gt;

&lt;p&gt;If you paste the share URL (including fragment) into an HTTP URL shortener that logs the full URL, or send it over an unencrypted channel that logs messages, the key is exposed. The fragment is only safe because HTTP requests don't include it — any logging of the full URL string exposes the key.&lt;/p&gt;

&lt;h3&gt;
  
  
  JavaScript injection on the share page
&lt;/h3&gt;

&lt;p&gt;If an attacker can inject JavaScript into the decryption page, they can read &lt;code&gt;location.hash&lt;/code&gt; before decryption. This is why zero-knowledge services need careful CSP headers and subresource integrity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key derivation vs. raw keys
&lt;/h3&gt;

&lt;p&gt;Passing raw AES keys in URL fragments is fine for ephemeral file sharing. For longer-lived keys, HKDF derivation from a passphrase gives more flexibility and lets you revoke access without sharing the raw key material.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The implementation described here is the basis of &lt;a href="https://fileshot.io" rel="noopener noreferrer"&gt;FileShot.io&lt;/a&gt; — a zero-knowledge file sharing service with an MIT-licensed backend you can self-host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/FileShot/FileShotZKE.git
&lt;span class="nb"&gt;cd &lt;/span&gt;FileShotZKE/backend &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open DevTools Network tab while using it and watch the upload/download requests — you'll never see a key.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The URL fragment has been part of the web for 30 years. It remains one of the most underused privacy primitives in browser engineering.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
    </item>
    <item>
      <title>Self-Hosting a Zero-Knowledge File Sharing Server in Under 10 Minutes</title>
      <dc:creator>FileShot</dc:creator>
      <pubDate>Tue, 03 Mar 2026 18:03:47 +0000</pubDate>
      <link>https://forem.com/fileshot_9818357dbe6cc693/self-hosting-a-zero-knowledge-file-sharing-server-in-under-10-minutes-1in5</link>
      <guid>https://forem.com/fileshot_9818357dbe6cc693/self-hosting-a-zero-knowledge-file-sharing-server-in-under-10-minutes-1in5</guid>
      <description>&lt;p&gt;If you share files with teammates, clients, or anyone outside your network, you’re almost certainly trusting a third-party server with your data. Even “encrypted” services often mean encryption in transit — the server still stores your plaintext.&lt;/p&gt;

&lt;p&gt;This guide shows you how to self-host &lt;strong&gt;FileShot.io&lt;/strong&gt;, a zero-knowledge file sharing server where the decryption key never reaches the server — ever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What “Zero-Knowledge” Actually Means
&lt;/h2&gt;

&lt;p&gt;Most file sharing services encrypt data &lt;em&gt;in transit&lt;/em&gt; (TLS) and &lt;em&gt;at rest&lt;/em&gt; (disk encryption). But the service still holds your encryption keys. If their servers are breached, subpoenaed, or compromised internally, your files are exposed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero-knowledge&lt;/strong&gt; means the server handles only ciphertext. The key lives nowhere on the server — not even transiently. FileShot implements this using the URL fragment (the &lt;code&gt;#&lt;/code&gt; part of the URL), which browsers strip from HTTP requests by specification. The server receives the file but genuinely cannot read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser                          Server
│                                │
├─ Generate AES-256-GCM key      │
├─ Encrypt file in memory        │
├─ Upload ciphertext ───────────&amp;gt; Store blob (can’t read it)
├─ Construct share URL           │
│    https://yourserver.com/d/{id}#{key}
│                                │
Recipient opens URL              │
├─ Browser strips # fragment       │
├─ Fetch ciphertext ────────────&amp;gt; Serve blob (still can’t read it)
├─ Decrypt with key from fragment  │
└─ File opens in browser           │
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The # fragment is a pure client-side transport mechanism. HTTP requests never include it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Linux server (Ubuntu 22.04+, Debian, or any modern distro)&lt;/li&gt;
&lt;li&gt;Node.js 18+ installed&lt;/li&gt;
&lt;li&gt;A domain or subdomain pointing at your server&lt;/li&gt;
&lt;li&gt;nginx or Caddy for reverse proxy (optional but recommended)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Clone and Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/FileShot/FileShotZKE.git
&lt;span class="nb"&gt;cd &lt;/span&gt;FileShotZKE/backend
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Configure Environment
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
nano .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PORT=3000
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=524288000   # 500MB default
FILE_RETENTION_HOURS=72   # Files auto-delete after 72h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Start the Server
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or with PM2 for production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; pm2
pm2 start server.js &lt;span class="nt"&gt;--name&lt;/span&gt; fileshot
pm2 save
pm2 startup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Serve the Frontend
&lt;/h2&gt;

&lt;p&gt;The frontend is a static HTML/JS/CSS site — no build step required. Serve it with nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;files.yourdomain.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/path/to/FileShotZKE/public_html/public_html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;index&lt;/span&gt; &lt;span class="s"&gt;index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="n"&gt;/index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:3000/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&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;Then enable HTTPS with Certbot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot &lt;span class="nt"&gt;--nginx&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; files.yourdomain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: Verify Zero-Knowledge Behavior
&lt;/h2&gt;

&lt;p&gt;Open browser DevTools → Network tab. Upload a file. You’ll see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /api/upload&lt;/code&gt; — request body is binary ciphertext, not your file&lt;/li&gt;
&lt;li&gt;The share URL fragment (&lt;code&gt;#...&lt;/code&gt;) is &lt;strong&gt;never sent&lt;/strong&gt; in any network request&lt;/li&gt;
&lt;li&gt;Server-side logs show only blob IDs, no key material&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can confirm this by inspecting the payload in Network tab — it’s AES-256-GCM ciphertext, indistinguishable from random bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Hardening
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# nginx rate limiting&lt;/span&gt;
limit_req_zone &lt;span class="nv"&gt;$binary_remote_addr&lt;/span&gt; &lt;span class="nv"&gt;zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;upload:10m &lt;span class="nv"&gt;rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10r/m&lt;span class="p"&gt;;&lt;/span&gt;
location /api/upload &lt;span class="o"&gt;{&lt;/span&gt;
    limit_req &lt;span class="nv"&gt;zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;upload &lt;span class="nv"&gt;burst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5&lt;span class="p"&gt;;&lt;/span&gt;
    proxy_pass http://127.0.0.1:3000/upload&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Auto-cleanup cron:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Runs every hour, deletes files older than 72h&lt;/span&gt;
0 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; find /path/to/uploads &lt;span class="nt"&gt;-mmin&lt;/span&gt; +4320 &lt;span class="nt"&gt;-delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fail2ban&lt;/strong&gt; to block upload abusers — add a filter for excessive 429 responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is Better Than WeTransfer or Dropbox for Sensitive Files
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;FileShot (self-hosted)&lt;/th&gt;
&lt;th&gt;WeTransfer&lt;/th&gt;
&lt;th&gt;Dropbox&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server sees plaintext&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No account needed&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hostable&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open source&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key in URL fragment&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Live Demo and Source
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live instance: &lt;strong&gt;&lt;a href="https://fileshot.io" rel="noopener noreferrer"&gt;https://fileshot.io&lt;/a&gt;&lt;/strong&gt; (free, no account)&lt;/li&gt;
&lt;li&gt;Source code: &lt;a href="https://github.com/FileShot/FileShotZKE" rel="noopener noreferrer"&gt;https://github.com/FileShot/FileShotZKE&lt;/a&gt; (MIT license)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The live instance is what you’re self-hosting here. If you just need instant sharing without self-hosting, fileshot.io works immediately.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Questions about the encryption implementation? Drop them below — happy to walk through the SubtleCrypto API implementation and how the IV, key derivation, and GCM tag are handled.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>The Hidden Risk in Every File Sharing Link (And the Zero-Knowledge Solution)</title>
      <dc:creator>FileShot</dc:creator>
      <pubDate>Tue, 03 Mar 2026 17:39:18 +0000</pubDate>
      <link>https://forem.com/fileshot_9818357dbe6cc693/the-hidden-risk-in-every-file-sharing-link-and-the-zero-knowledge-solution-lph</link>
      <guid>https://forem.com/fileshot_9818357dbe6cc693/the-hidden-risk-in-every-file-sharing-link-and-the-zero-knowledge-solution-lph</guid>
      <description>&lt;p&gt;Every time you share a file via Google Drive, Dropbox, or WeTransfer, you're making an implicit trust decision: &lt;em&gt;I trust this server to not read my file.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For most files, that's fine. For sensitive files — contracts, credentials, medical records, source code — it's a significant risk that most developers ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Server-Side Trust
&lt;/h2&gt;

&lt;p&gt;When you upload a file to a typical sharing service:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your file travels over HTTPS to their server&lt;/li&gt;
&lt;li&gt;Their server stores it (usually encrypted at rest, but &lt;em&gt;they hold the key&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;They give you a share link&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The server itself can read your file. So can employees with database access, law enforcement with a subpoena, and attackers who compromise their infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Zero-Knowledge" Actually Means
&lt;/h2&gt;

&lt;p&gt;Two conditions must both be true:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Client-side encryption&lt;/strong&gt; — the file is encrypted &lt;em&gt;before&lt;/em&gt; leaving your browser&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key never reaches the server&lt;/strong&gt; — the decryption key is delivered out-of-band&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;HTTPS alone does not count — the server decrypts on arrival. Server-managed encryption does not count — they still hold the key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The URL Fragment Solution
&lt;/h2&gt;

&lt;p&gt;The hash fragment (#) part of a URL has a critical property: &lt;strong&gt;browsers never include it in HTTP requests.&lt;/strong&gt; This is specified in RFC 3986.&lt;/p&gt;

&lt;p&gt;This makes URL fragments a natural out-of-band channel for key delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  How FileShot.io Implements This
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://fileshot.io" rel="noopener noreferrer"&gt;FileShot.io&lt;/a&gt; uses the Web Crypto API for AES-256-GCM encryption entirely in the browser:&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="c1"&gt;// Generate 256-bit AES-GCM key&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="kc"&gt;true&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;encrypt&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;decrypt&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="c1"&gt;// Random 96-bit IV (correct for GCM)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Encrypt the file buffer&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;fileBuffer&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Export key for fragment delivery&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exportKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&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;b64Key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawKey&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="c1"&gt;// Share URL: https://fileshot.io/d/FILE_ID + "#" + b64Key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server receives only the encrypted file and a random file ID — mathematically incapable of decryption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Implications
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Protected against:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server compromise — attacker gets ciphertext only&lt;/li&gt;
&lt;li&gt;Subpoenas — server has nothing to hand over&lt;/li&gt;
&lt;li&gt;Employee access — no plaintext stored&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;NOT protected against:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compromised browser/endpoint&lt;/li&gt;
&lt;li&gt;Sharing the complete URL to an untrusted party&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://fileshot.io" rel="noopener noreferrer"&gt;fileshot.io&lt;/a&gt; — no account needed, free, no file size limit.&lt;/p&gt;

&lt;p&gt;Self-hosted: &lt;a href="https://github.com/FileShot/FileShotZKE" rel="noopener noreferrer"&gt;github.com/FileShot/FileShotZKE&lt;/a&gt; (MIT)&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>How Zero-Knowledge File Sharing Works: AES-256-GCM in the Browser</title>
      <dc:creator>FileShot</dc:creator>
      <pubDate>Tue, 03 Mar 2026 17:12:48 +0000</pubDate>
      <link>https://forem.com/fileshot_9818357dbe6cc693/how-zero-knowledge-file-sharing-works-aes-256-gcm-in-the-browser-49mo</link>
      <guid>https://forem.com/fileshot_9818357dbe6cc693/how-zero-knowledge-file-sharing-works-aes-256-gcm-in-the-browser-49mo</guid>
      <description>&lt;p&gt;When you upload a file to most cloud services — Google Drive, Dropbox, WeTransfer — the server receives your file in plaintext before encrypting it on their end. That means they &lt;em&gt;can&lt;/em&gt; read it. They have the key.&lt;/p&gt;

&lt;p&gt;Zero-knowledge file sharing is different: &lt;strong&gt;the server never receives the plaintext file at all&lt;/strong&gt;. Encryption happens entirely in the browser before a single byte is transmitted.&lt;/p&gt;

&lt;p&gt;Here's exactly how it works, using &lt;a href="https://fileshot.io" rel="noopener noreferrer"&gt;FileShot.io&lt;/a&gt; as a working example (MIT open source, self-hostable).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Concept: URL Fragments
&lt;/h2&gt;

&lt;p&gt;The trick that makes zero-knowledge sharing practical is the URL fragment — the part after the &lt;code&gt;#&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://fileshot.io/d/abc123#AES_KEY_HERE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Browsers &lt;strong&gt;never send the fragment to the server&lt;/strong&gt;. It is not included in HTTP requests, not logged in server access logs, not sent in Referer headers. The &lt;code&gt;#KEY&lt;/code&gt; part stays purely client-side.&lt;/p&gt;

&lt;p&gt;This is how the decryption key travels with the share link without the server ever seeing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Encryption Flow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Key generation&lt;/strong&gt;: &lt;code&gt;crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"])&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypt before upload&lt;/strong&gt;: File bytes are encrypted with AES-256-GCM locally; the server receives only ciphertext&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key export and encoding&lt;/strong&gt;: The key is exported and base64url-encoded into the URL fragment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share&lt;/strong&gt;: The recipient gets a link like &lt;code&gt;fileshot.io/d/ID#KEY&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decrypt&lt;/strong&gt;: Recipient's browser extracts the key from &lt;code&gt;location.hash&lt;/code&gt;, fetches the ciphertext, decrypts locally&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why AES-GCM Specifically
&lt;/h2&gt;

&lt;p&gt;AES-GCM is an &lt;strong&gt;Authenticated Encryption with Associated Data (AEAD)&lt;/strong&gt; mode. It provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Confidentiality&lt;/strong&gt;: Ciphertext reveals nothing about plaintext&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity&lt;/strong&gt;: Any tampering with the ciphertext is detectable — the &lt;code&gt;subtle.decrypt&lt;/code&gt; call will throw an error&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance&lt;/strong&gt;: Hardware-accelerated on modern CPUs (AES-NI)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GCM uses a random 96-bit nonce (IV) per encryption operation, which is stored alongside the ciphertext.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Server Sees
&lt;/h2&gt;

&lt;p&gt;The server receives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An opaque blob of ciphertext&lt;/li&gt;
&lt;li&gt;A random file ID&lt;/li&gt;
&lt;li&gt;An expiry time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The server &lt;strong&gt;cannot decrypt the file&lt;/strong&gt; even if compelled — it has never seen the key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://fileshot.io" rel="noopener noreferrer"&gt;FileShot.io&lt;/a&gt; is a production implementation of this pattern, free to use, MIT licensed, and self-hostable at &lt;a href="https://github.com/FileShot/FileShotZKE" rel="noopener noreferrer"&gt;github.com/FileShot/FileShotZKE&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Zero-knowledge encryption is not complicated — it just requires keeping the key out of the server's reach entirely.&lt;/p&gt;

</description>
      <category>security</category>
      <category>encryption</category>
      <category>privacy</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I built zero-knowledge file sharing in the browser (AES-256-GCM, keys never leave the client)</title>
      <dc:creator>FileShot</dc:creator>
      <pubDate>Tue, 03 Mar 2026 13:58:32 +0000</pubDate>
      <link>https://forem.com/fileshot_9818357dbe6cc693/how-i-built-zero-knowledge-file-sharing-in-the-browser-aes-256-gcm-keys-never-leave-the-client-33bm</link>
      <guid>https://forem.com/fileshot_9818357dbe6cc693/how-i-built-zero-knowledge-file-sharing-in-the-browser-aes-256-gcm-keys-never-leave-the-client-33bm</guid>
      <description>&lt;p&gt;Most "end-to-end encrypted" tools still send your key to the server at some point. I wanted to build something where the server is architecturally &lt;em&gt;incapable&lt;/em&gt; of decrypting your file — not just policy-based, but mathematically impossible. Here's exactly how I built it for &lt;a href="https://fileshot.io" rel="noopener noreferrer"&gt;FileShot.io&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: the URL fragment is your vault
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;#fragment&lt;/code&gt; part of a URL (everything after the &lt;code&gt;#&lt;/code&gt;) is &lt;strong&gt;never sent to the server&lt;/strong&gt;. The browser strips it before making any HTTP request. This is a well-known browser spec behavior, but most developers never exploit it for security.&lt;/p&gt;

&lt;p&gt;That single fact powers the entire zero-knowledge model:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate a random 256-bit AES key &lt;strong&gt;in the browser&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Encrypt the file with that key using AES-256-GCM&lt;/li&gt;
&lt;li&gt;Upload only the ciphertext — the server gets bytes it cannot read&lt;/li&gt;
&lt;li&gt;Put the key in the URL fragment: &lt;code&gt;https://fileshot.io/d/abc123#&amp;lt;base64_key&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Share the full URL — the recipient's browser decrypts locally&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The server never sees the key. Not during upload, not during download, not ever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The encryption code
&lt;/h2&gt;

&lt;p&gt;Here's the actual implementation (simplified from production):&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;encryptFile&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Generate a fresh key for every file&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// extractable — we need to put it in the URL&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;encrypt&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;decrypt&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="c1"&gt;// Random 96-bit IV — never reuse an IV with the same key&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&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;plaintext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;plaintext&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Export key as raw bytes, then base64url-encode it&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exportKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&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;keyB64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bufferToBase64url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// IV is prepended to ciphertext so the recipient can extract it&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iv&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;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;encryptedBlob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keyB64&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;After upload, the share URL is assembled as:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shareUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://fileshot.io/d/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fileId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;#&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;keyB64&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The decryption code
&lt;/h2&gt;

&lt;p&gt;On the recipient's side, the key is read straight from &lt;code&gt;window.location.hash&lt;/code&gt;:&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;decryptDownload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encryptedBuffer&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;keyB64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="c1"&gt;// strip the '#'&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base64urlToBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keyB64&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rawKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// non-extractable on the recipient side&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;decrypt&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="c1"&gt;// Extract the 12-byte IV prepended during encryption&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;encryptedBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;12&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;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;encryptedBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&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;plaintext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;ciphertext&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;plaintext&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;No server calls. No key derivation. Just &lt;code&gt;crypto.subtle&lt;/code&gt; doing the work locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AES-GCM specifically?
&lt;/h2&gt;

&lt;p&gt;AES-GCM gives you &lt;strong&gt;authenticated encryption&lt;/strong&gt; — it detects tampering. If someone on the server modified even one byte of the ciphertext, decryption fails with an error. You get both confidentiality and integrity in one primitive.&lt;/p&gt;

&lt;p&gt;GCM also produces a 128-bit authentication tag automatically appended to the ciphertext. &lt;code&gt;crypto.subtle&lt;/code&gt; handles all of this transparently.&lt;/p&gt;

&lt;p&gt;The alternative (AES-CBC) requires a separate HMAC for integrity, is vulnerable to padding oracle attacks if misimplemented, and doesn't parallelize as well. GCM is the right choice for file encryption in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server actually sees
&lt;/h2&gt;

&lt;p&gt;Here's the full picture of what hits the FileShot backend:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;Server sees?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File contents&lt;/td&gt;
&lt;td&gt;No — only encrypted bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encryption key&lt;/td&gt;
&lt;td&gt;No — never transmitted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Original filename&lt;/td&gt;
&lt;td&gt;Optional — can be omitted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File size&lt;/td&gt;
&lt;td&gt;Yes — needed for storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uploader IP&lt;/td&gt;
&lt;td&gt;Yes — standard HTTP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access count&lt;/td&gt;
&lt;td&gt;Yes — for link expiry logic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The server is a dumb storage layer. It stores opaque blobs identified by random IDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attack surface I thought hard about
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Link interception&lt;/strong&gt;: If an attacker intercepts the full URL (including fragment), they get the key. This is the fundamental trade-off of fragment-based ZKE — the link IS the key. Mitigations: burn-after-read links, download limits, short expiry times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser history&lt;/strong&gt;: The fragment appears in browser history. Users should be aware that the shared link persists locally. Open-source desktop apps or Incognito mode address this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-side JS injection&lt;/strong&gt;: If I served malicious JavaScript that exfiltrated the fragment, ZKE is broken. This is why &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity" rel="noopener noreferrer"&gt;Subresource Integrity&lt;/a&gt; matters — lock your JS with SRI hashes if you want to be rigorous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory attacks&lt;/strong&gt;: The plaintext lives briefly in the browser's JS heap during encrypt/decrypt. This is unavoidable with in-browser crypto. It's a much smaller window than traditional server-side encryption where plaintext sits at rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;Using the WebCrypto API (&lt;code&gt;crypto.subtle&lt;/code&gt;) means the browser runs AES-GCM through native code, not a JS polyfill. On modern hardware this saturates network bandwidth:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 MB file → encrypted and ready in ~8ms&lt;/li&gt;
&lt;li&gt;50 MB file → ~400ms encryption time&lt;/li&gt;
&lt;li&gt;Upload is I/O bound, not CPU bound&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice, &lt;a href="https://fileshot.io" rel="noopener noreferrer"&gt;FileShot&lt;/a&gt; completes a 1 MB upload (encrypt + transfer + link ready) in around 1.6 seconds on a decent connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming large files
&lt;/h2&gt;

&lt;p&gt;One limitation: &lt;code&gt;crypto.subtle.encrypt&lt;/code&gt; requires the full plaintext in memory before producing ciphertext. For files above ~500 MB this is a concern on constrained devices.&lt;/p&gt;

&lt;p&gt;The workaround for truly large files is chunked encryption — split the file into 64 MB chunks, encrypt each independently with the same key but unique IVs, upload chunks in parallel, and on download decrypt and stream chunks to disk as they arrive. This is on the FileShot roadmap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The UX challenge
&lt;/h2&gt;

&lt;p&gt;Zero-knowledge means &lt;strong&gt;no password recovery&lt;/strong&gt;. If you lose the link containing the key, the file is gone forever — you cannot ask the server to "reset" it. This is the correct behavior, but it's jarring for users arriving from Dropbox or Google Drive.&lt;/p&gt;

&lt;p&gt;The solution is clear UI copy: "The download link IS the decryption key. Save it." FileShot shows this prominently before the link disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Everything described here is live at &lt;a href="https://fileshot.io" rel="noopener noreferrer"&gt;FileShot.io&lt;/a&gt; — no account required, free for files up to 50 GB. The zero-knowledge model applies to every file uploaded.&lt;/p&gt;

&lt;p&gt;If you're building something similar, the two things worth emphasizing: use AES-GCM (not CBC), and prepend the IV to the ciphertext so the download side doesn't need a separate round-trip to get it.&lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments — especially if you've run into the streaming large files problem.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>security</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Building an agentic AI assistant that runs entirely in your browser with no cloud required</title>
      <dc:creator>FileShot</dc:creator>
      <pubDate>Tue, 24 Feb 2026 12:28:35 +0000</pubDate>
      <link>https://forem.com/fileshot_9818357dbe6cc693/building-an-agentic-ai-assistant-that-runs-entirely-in-your-browser-with-no-cloud-required-app</link>
      <guid>https://forem.com/fileshot_9818357dbe6cc693/building-an-agentic-ai-assistant-that-runs-entirely-in-your-browser-with-no-cloud-required-app</guid>
      <description>&lt;p&gt;"Agentic AI" has become a marketing term that means everything and nothing. Most "agentic" tools are just language models with tool use, where the tools make API calls to cloud services, and the orchestration happens on some company's server.&lt;/p&gt;

&lt;p&gt;I wanted to build something genuinely different: an agentic assistant where the entire execution — the model inference, the tool calls, the memory — happens on your device.&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;Pocket guIDE&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "agentic" actually means here
&lt;/h2&gt;

&lt;p&gt;Pocket guIDE can execute multi-step tasks autonomously. Given a goal, it will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Break the goal into steps&lt;/li&gt;
&lt;li&gt;Execute each step (searching, reading, writing, calculating)&lt;/li&gt;
&lt;li&gt;Use the output of each step to inform the next&lt;/li&gt;
&lt;li&gt;Return a consolidated result&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key distinction from a chatbot: it doesn't just respond to your message. It acts on it over multiple iterations until the task is done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What tools does it have access to?
&lt;/h2&gt;

&lt;p&gt;The agent has access to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web search&lt;/strong&gt; — via a local search proxy, not through a cloud AI service&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calculator / code execution&lt;/strong&gt; — runs JavaScript expressions in a sandboxed worker&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Note-taking / memory&lt;/strong&gt; — persists context between sessions in local storage (&lt;code&gt;E:\pocketguidestorage&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document reading&lt;/strong&gt; — can process PDFs and text files you drop in&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conversation mode&lt;/strong&gt; — standard back-and-forth when you don't need multi-step execution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All this happens in the browser. The model inference runs on your CPU/GPU via WebAssembly-compiled llama.cpp (or via a local llama.cpp server if you want faster responses).&lt;/p&gt;

&lt;h2&gt;
  
  
  The privacy story
&lt;/h2&gt;

&lt;p&gt;Because inference and tool execution are local:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your conversations aren't logged anywhere&lt;/strong&gt; — no company has a history of what you've asked&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your files stay on your machine&lt;/strong&gt; — documents you analyze never leave your device&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No API key exposure&lt;/strong&gt; — there's no key to leak or rotate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Works offline&lt;/strong&gt; — no internet required for the AI components&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest trade-offs
&lt;/h2&gt;

&lt;p&gt;Running everything locally means your model is bounded by your hardware. A WebAssembly-compiled 3B model running in the browser is noticeably slower and less capable than GPT-4 over an API.&lt;/p&gt;

&lt;p&gt;For tasks that need frontier model capability — complex reasoning, very long context — local models aren't there yet. For everyday assistant tasks, research summaries, and multi-step workflows on local documents, they're surprisingly capable.&lt;/p&gt;

&lt;p&gt;The experience is better when running Pocket guIDE with a local llama.cpp server (accessible at &lt;code&gt;localhost&lt;/code&gt;) rather than full WASM inference. The server can use your GPU and the larger quantized models.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The frontend is a React application that communicates with a local inference backend (llama.cpp server or WASM). The agent loop is implemented in TypeScript:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;goal&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="nx"&gt;maxIterations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;goal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;maxIterations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&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;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;finish&lt;/span&gt;&lt;span class="dl"&gt;'&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;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&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;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="p"&gt;});&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;The tool system is pluggable — adding a new tool means implementing a simple interface and registering it with the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Pocket guIDE runs in any modern browser. For the best experience, run a local llama.cpp server alongside it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pocket.graysoft.dev" rel="noopener noreferrer"&gt;pocket.graysoft.dev&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with: React, TypeScript, llama.cpp (WASM + local server), WebAssembly, IndexedDB&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>javascript</category>
      <category>programming</category>
    </item>
    <item>
      <title>I built a free technical SEO audit tool that requires no login — here's how it works</title>
      <dc:creator>FileShot</dc:creator>
      <pubDate>Tue, 24 Feb 2026 12:27:59 +0000</pubDate>
      <link>https://forem.com/fileshot_9818357dbe6cc693/i-built-a-free-technical-seo-audit-tool-that-requires-no-login-heres-how-it-works-2fgn</link>
      <guid>https://forem.com/fileshot_9818357dbe6cc693/i-built-a-free-technical-seo-audit-tool-that-requires-no-login-heres-how-it-works-2fgn</guid>
      <description>&lt;p&gt;Most serious SEO audit tools sit behind a login, a trial period, or a monthly subscription. Ahrefs, SEMrush, Moz — all great, all expensive. The tools that are free are usually watered-down or lead-gen for upselling.&lt;/p&gt;

&lt;p&gt;I wanted to build something that gives a genuinely useful technical audit to anyone, immediately, for free. No signup, no email address, no credit card.&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;SEODoc&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it checks
&lt;/h2&gt;

&lt;p&gt;Paste a URL and SEODoc crawls the page and generates a report covering:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical health:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP status codes and redirects&lt;/li&gt;
&lt;li&gt;Page speed and Core Web Vitals estimates&lt;/li&gt;
&lt;li&gt;Mobile-friendliness indicators&lt;/li&gt;
&lt;li&gt;HTTPS and security headers&lt;/li&gt;
&lt;li&gt;Canonical tags and hreflang&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On-page SEO:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Title tag length, uniqueness, keyword presence&lt;/li&gt;
&lt;li&gt;Meta description length and quality signals&lt;/li&gt;
&lt;li&gt;Heading hierarchy (H1-H6) analysis
&lt;/li&gt;
&lt;li&gt;Image alt text coverage&lt;/li&gt;
&lt;li&gt;Internal/external link audit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Structured data:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Schema.org markup detection and validation&lt;/li&gt;
&lt;li&gt;Open Graph and Twitter Card tags&lt;/li&gt;
&lt;li&gt;JSON-LD validation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Crawlability:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Robots.txt analysis&lt;/li&gt;
&lt;li&gt;Sitemap detection and validation&lt;/li&gt;
&lt;li&gt;Noindex/nofollow tag detection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The output is a structured report with pass/fail indicators and specific recommendations for each issue found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The technical approach
&lt;/h2&gt;

&lt;p&gt;The backend is a Python FastAPI application. The crawling is done with a combination of Playwright (for JavaScript-rendered pages) and BeautifulSoup (for static HTML parsing). This means it handles modern SPAs properly, not just server-rendered HTML.&lt;/p&gt;

&lt;p&gt;The Core Web Vitals estimation uses Lighthouse programmatically via headless Chrome. The structured data validation uses Google's structured data testing logic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/audit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AuditRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;crawler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SEOCrawler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_full_audit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AuditResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;technical&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;technical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;onpage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onpage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;structured_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;structured_data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;recommendations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prioritized_recommendations&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;h2&gt;
  
  
  Why no login
&lt;/h2&gt;

&lt;p&gt;Every "free SEO tool" I've seen uses the no-login experience as a lead funnel. You get the results and then hit a wall: "Sign up to see the full report." I wanted to do the opposite — give the full report upfront, no strings.&lt;/p&gt;

&lt;p&gt;The business model is batch auditing and scheduled monitoring, which is a paid feature for when you want to run audits on 50 pages regularly. The one-off audit is just free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned building it
&lt;/h2&gt;

&lt;p&gt;JavaScript-rendered pages are a surprisingly large percentage of the modern web. If you only use requests + BeautifulSoup, you miss entire categories of content and issues. Running Playwright for every audit adds latency but is necessary for accuracy.&lt;/p&gt;

&lt;p&gt;Core Web Vitals are genuinely hard to estimate without running real browser measurement. The Lighthouse approach is good enough for directional guidance but not as reliable as field data from CrUX.&lt;/p&gt;

&lt;p&gt;Try it at &lt;a href="https://seodoc.site" rel="noopener noreferrer"&gt;seodoc.site&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with: Python, FastAPI, Playwright, BeautifulSoup, Lighthouse, Pydantic&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>seo</category>
      <category>webdev</category>
      <category>python</category>
    </item>
    <item>
      <title>Building a marketplace for indie developers to buy and sell projects, domains, and templates</title>
      <dc:creator>FileShot</dc:creator>
      <pubDate>Tue, 24 Feb 2026 12:27:24 +0000</pubDate>
      <link>https://forem.com/fileshot_9818357dbe6cc693/building-a-marketplace-for-indie-developers-to-buy-and-sell-projects-domains-and-templates-paf</link>
      <guid>https://forem.com/fileshot_9818357dbe6cc693/building-a-marketplace-for-indie-developers-to-buy-and-sell-projects-domains-and-templates-paf</guid>
      <description>&lt;p&gt;Indie developers build a lot of things that don't go anywhere. A side project that got to MVP and stalled. A domain with a good name that never got built out. A template built for one project that would genuinely save someone else weeks of work.&lt;/p&gt;

&lt;p&gt;The problem is there's nowhere great to sell these.&lt;/p&gt;

&lt;p&gt;Flippa is overkill (and expensive) for anything generating under $1K/month. Reddit's r/entrepreneur has occasional "for sale" posts but no proper listing format, no discovery, no escrow. Most of the time, that domain or side project just sits around gathering dust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iStack&lt;/strong&gt; is my answer to that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can list
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Side projects&lt;/strong&gt; — Fully working apps and websites, with or without revenue&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domains&lt;/strong&gt; — Good domain names with or without existing traffic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Templates&lt;/strong&gt; — Code templates, UI kits, starter projects (like the kind I sell through DiggaByte)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SaaS products&lt;/strong&gt; — Software businesses at any stage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code packages&lt;/strong&gt; — One-off scripts, libraries, components&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Sellers create a listing with description, tech stack, revenue (if any), traffic stats, and asking price. The listing stays up until sold or removed.&lt;/p&gt;

&lt;p&gt;Buyers can browse by category, price range, tech stack, or revenue tier. There's no intermediary blocking contact — interested buyers message sellers directly and negotiate terms.&lt;/p&gt;

&lt;p&gt;For higher-value transactions, the platform facilitates escrow through a third-party provider so neither party is taking a blind leap of faith.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built with
&lt;/h2&gt;

&lt;p&gt;The stack is Next.js App Router + Prisma + PostgreSQL. User accounts, listing management, messaging, and escrow integration. Authentication via NextAuth.&lt;/p&gt;

&lt;p&gt;One deliberate constraint: no algorithmic feed, no engagement-bait ranking. Listings are sorted by recency and category. The browsing experience is meant to feel like a classifieds board, not a social feed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why build this
&lt;/h2&gt;

&lt;p&gt;I've had side projects I wanted to sell and had nowhere to list them. I've had domains I wanted to offload and ended up just letting them expire. The existing platforms are either too focused on big revenue businesses or too informal to give buyers any confidence.&lt;/p&gt;

&lt;p&gt;There's a real market for "decent indie thing, reasonable price, no drama" transactions.&lt;/p&gt;

&lt;p&gt;Explore listings at &lt;a href="https://istack.site" rel="noopener noreferrer"&gt;istack.site&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with: Next.js, Prisma, PostgreSQL, NextAuth, TypeScript, Tailwind&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>sideprojects</category>
      <category>indie</category>
    </item>
    <item>
      <title>Building automated crypto trading bots with copy trading — what I learned</title>
      <dc:creator>FileShot</dc:creator>
      <pubDate>Tue, 24 Feb 2026 12:26:48 +0000</pubDate>
      <link>https://forem.com/fileshot_9818357dbe6cc693/building-automated-crypto-trading-bots-with-copy-trading-what-i-learned-4c9b</link>
      <guid>https://forem.com/fileshot_9818357dbe6cc693/building-automated-crypto-trading-bots-with-copy-trading-what-i-learned-4c9b</guid>
      <description>&lt;p&gt;Algorithmic trading is one of those areas where the gap between "sounds simple" and "is actually complex" is enormous.&lt;/p&gt;

&lt;p&gt;The basic loop sounds easy: watch prices, detect a signal, place an order. But production-grade algotrading infrastructure needs to handle exchange API rate limits, partial fills, order state reconciliation across disconnects, risk management overrides, and a dozen other edge cases that only show up when real money is on the line.&lt;/p&gt;

&lt;p&gt;That's what I spent months building into &lt;strong&gt;ZipDex&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ZipDex does
&lt;/h2&gt;

&lt;p&gt;ZipDex is a crypto trading automation platform with three main features:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Strategy bots&lt;/strong&gt;&lt;br&gt;
Rule-based trading strategies you can configure without writing code. Common strategies like grid trading, DCA (dollar-cost averaging), MACD/RSI signal bots, and more. Set your parameters, connect your exchange API keys, and let it run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Copy trading&lt;/strong&gt;&lt;br&gt;
Follow other traders' strategies automatically. When a trader you follow opens a position, your account mirrors it proportionally. This is the feature that took the longest to build correctly — the synchronization logic between the source account and all mirror accounts is genuinely tricky.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. AI-assisted strategies&lt;/strong&gt;&lt;br&gt;
LLM-powered analysis layered on top of technical indicators. Not autonomous trading — more like a second opinion before entries. The AI component reads market context and sentiment and flags conditions where standard indicators might be misleading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard parts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Exchange connectivity&lt;/strong&gt; — Crypto exchanges have wildly inconsistent APIs. Rate limits vary, error codes aren't standardized, and WebSocket reliability is a joke on some platforms. I built an abstraction layer over the top of &lt;a href="https://github.com/ccxt/ccxt" rel="noopener noreferrer"&gt;ccxt&lt;/a&gt; to normalize this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Order reconciliation&lt;/strong&gt; — WebSocket connections drop. When you reconnect, you need to reconcile your local order state with the exchange's actual state. Getting this wrong means missing fills or double-executing orders. This was the most failure-prone part of the system to build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Risk management&lt;/strong&gt; — Position size limits, maximum drawdown stops, per-trade risk percentages. These need to run synchronously before any order is placed and cannot be bypassed by any strategy. I built these as a non-negotiable layer that every order passes through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Copy trading latency&lt;/strong&gt; — When copying a trader, latency matters. A 500ms delay on a fast-moving entry can mean a significantly different fill price. The copy execution pipeline is one of the most optimized parts of the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it's built with
&lt;/h2&gt;

&lt;p&gt;Node.js backend with a Redis queue for order execution, PostgreSQL for trade history and state, WebSocket connections maintained with exponential backoff reconnection, and a React frontend for configuration and monitoring.&lt;/p&gt;

&lt;p&gt;The AI component uses local LLM inference so no trading data leaves the machine.&lt;/p&gt;

&lt;p&gt;Check it out at &lt;a href="https://zipdex.io" rel="noopener noreferrer"&gt;zipdex.io&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with: Node.js, Express, Redis, PostgreSQL, WebSockets, ccxt, React&lt;/em&gt;&lt;/p&gt;

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