Encrypting Files Before IPFS: A Privacy Guide for Decentralized Storage

IPFS is public by default. Learn how to encrypt files client-side before pinning them to IPFS for private, decentralized storage.

Nacho Collby Updated: 9 min read
IPFS is public by default. Learn how to encrypt files client-side before pinning them to IPFS for private, decentralized storage.

Anyone who has the CID can fetch the content behind it. That’s the whole point of content addressing — but it also means IPFS is public by default. There’s no access-control layer at the protocol level: if a CID leaks (log line, browser history, a shared link), the content is retrievable from any gateway that has it pinned. If you need privacy, you encrypt before you pin — not after.

IPFS Ninja

Encrypt and pin a file in under 30 seconds#

This uses the browser’s native Web Crypto API (AES-256-GCM) to encrypt a payload client-side, then pins the ciphertext to IPFS. The plaintext never leaves the browser.

async function encryptAndPin(plaintext, apiKey) {
  const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(plaintext);

  const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
  const rawKey = await crypto.subtle.exportKey('raw', key);

  const payload = {
    iv: btoa(String.fromCharCode(...iv)),
    ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertext))),
  };

  const response = await fetch('https://api.ipfs.ninja/upload/new', {
    method: 'POST',
    headers: { 'X-Api-Key': apiKey, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      content: btoa(JSON.stringify(payload)),
      description: 'encrypted payload',
    }),
  });

  const { cid, uris } = await response.json();
  // Store this key somewhere the ciphertext isn't — see "Key management" below.
  const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(rawKey)));
  return { cid, url: uris.url, keyBase64 };
}

encryptAndPin('the actual secret content', 'bws_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4')
  .then(({ cid, keyBase64 }) => console.log('pinned', cid, 'key (keep this separate!)', keyBase64));

Fetch the CID from any gateway and you’ll see base64 ciphertext — unreadable without the key. That’s the property you’re after: the storage layer is public, the content isn’t.

Approaches at a glance#

ApproachPlaintext ever leaves your device?Who can read the pinned content?Effort
Upload plaintext, no encryptionYesAnyone with the CIDNone
Server-side encryption before uploadYes (to your server)Only holders of the keyLow
Client-side encryption (this guide)NoOnly holders of the keyLow-medium
Gateway access controls (dedicated gateway)YesAnyone allowed through the gateway’s access modeLow, but relies on the gateway staying the only access path

Client-side encryption is the only row where nobody — including the pinning service — ever sees plaintext. A restricted dedicated gateway limits access paths, but the underlying object is still plaintext at rest, so it’s a weaker guarantee than encrypting the bytes themselves. Use both together if you want defense in depth.

Why “public by default” matters here#

A CID is a hash of the content, not a permission check. GET https://ipfs.ninja/ipfs/{cid} (or any other gateway that has the content pinned) returns the bytes to whoever asks — there’s no auth step, because the protocol wasn’t designed to have one. That’s a feature for public assets (NFT metadata, static sites, open datasets) and a liability for anything sensitive: user documents, private datasets, personal data, internal files.

The fix isn’t a special “private IPFS” mode — it’s standard client-side encryption applied before the content ever gets hashed into a CID. Once the ciphertext is what’s pinned, the CID itself becomes useless to an attacker without the decryption key.

AES-256-GCM: the encryption you actually want#

AES-256-GCM is the standard choice for this pattern for two reasons: it’s authenticated (GCM’s tag detects tampering — an attacker who modifies the ciphertext gets a decryption failure, not silently-corrupted plaintext), and it’s fast enough to run in a browser tab on files of realistic size without noticeable delay.

The three pieces you need per file:

  • Key — 256-bit symmetric key, generated per-file or per-user depending on your key management model.
  • IV (nonce) — 12 random bytes, unique per encryption operation. Never reuse an IV with the same key.
  • Ciphertext + auth tag — the encrypted bytes plus the tag GCM appends automatically (the Web Crypto API and Python’s cryptography library both handle this for you).

You need the IV to decrypt later, so store it alongside the ciphertext (as in the example above) — the IV doesn’t need to be secret, only the key does.

Key management options#

The encryption step is the easy part. The real design decision is where the decryption key lives, because that’s what actually gates access:

  1. Per-user key, stored in your backend DB. Simplest model. Your server holds the key, decrypts on behalf of authenticated users. Works well when access control already lives in your app.
  2. Per-file key, wrapped by a user’s key. Generate a random key per file, encrypt that key with the user’s master key (password-derived or otherwise), and store the wrapped key next to your file metadata. Lets you share individual files without exposing a user’s whole key.
  3. Client-held key, never sent to your server. The key lives only in the browser (or a password-derived key via PBKDF2/Argon2, re-derived on each session) and your backend never sees plaintext or key. Strongest guarantee, but you lose server-side recovery — if the user loses the key, the data is unrecoverable by design.
  4. Key exchange for sharing. If two parties need to share one encrypted file, wrap the AES key with the recipient’s public key (RSA-OAEP or ECDH) instead of sending the raw key over a side channel.

Pick based on your threat model: option 3 is what you want if “our own service” is inside the threat model (compliance, insider risk); option 1 is fine if you just don’t want the object public to the internet at large.

JavaScript: full encrypt → pin → fetch → decrypt cycle#

async function deriveKeyFromPassword(password, salt) {
  const baseKey = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 250000, hash: 'SHA-256' },
    baseKey,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

async function decryptFromCid(cid, password) {
  const res = await fetch(`https://ipfs.ninja/ipfs/${cid}`);
  const { salt, iv, ciphertext } = JSON.parse(atob(await res.text()));

  const key = await deriveKeyFromPassword(password, Uint8Array.from(atob(salt), c => c.charCodeAt(0)));
  const plaintextBuf = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: Uint8Array.from(atob(iv), c => c.charCodeAt(0)) },
    key,
    Uint8Array.from(atob(ciphertext), c => c.charCodeAt(0))
  );

  return new TextDecoder().decode(plaintextBuf);
}

A wrong password produces a decryption error (GCM’s tag check fails) rather than garbage output — that’s the authenticated part of AES-GCM working as intended.

Python: encrypt before upload#

import base64
import json
import os
import requests
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def encrypt_and_pin(plaintext: bytes, api_key: str):
    key = AESGCM.generate_key(bit_length=256)
    aesgcm = AESGCM(key)
    iv = os.urandom(12)
    ciphertext = aesgcm.encrypt(iv, plaintext, None)

    payload = {
        "iv": base64.b64encode(iv).decode(),
        "ciphertext": base64.b64encode(ciphertext).decode(),
    }

    response = requests.post(
        "https://api.ipfs.ninja/upload/new",
        headers={"X-Api-Key": api_key, "Content-Type": "application/json"},
        json={
            "content": base64.b64encode(json.dumps(payload).encode()).decode(),
            "description": "encrypted payload",
        },
    )
    response.raise_for_status()
    result = response.json()
    return result["cid"], result["uris"]["url"], base64.b64encode(key).decode()

cid, url, key_b64 = encrypt_and_pin(b"the actual secret content", "bws_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4")
print(cid, "key (store separately):", key_b64)
def fetch_and_decrypt(cid: str, key_b64: str) -> bytes:
    res = requests.get(f"https://ipfs.ninja/ipfs/{cid}")
    payload = json.loads(base64.b64decode(res.text))
    key = base64.b64decode(key_b64)
    aesgcm = AESGCM(key)
    iv = base64.b64decode(payload["iv"])
    ciphertext = base64.b64decode(payload["ciphertext"])
    return aesgcm.decrypt(iv, ciphertext, None)

Practical checklist#

  • Never reuse an IV with the same key. Generate a fresh random IV per encryption call — the examples above already do this.
  • Store the key separately from the ciphertext. If both live in the same place (e.g., both in the same DB row), you’ve defeated the purpose — anyone with read access to that row gets both.
  • Encrypt before you hash. The CID is derived from the ciphertext bytes, so encryption has to happen before the upload call, not as a step you bolt on after.
  • Binary files work the same way — read the file into bytes, encrypt those bytes, base64-encode the ciphertext into the JSON content field exactly like the text examples above. Large binaries add latency for the base64 round-trip; for very large files, encrypt and compress before upload.
  • Metadata isn’t automatically encrypted. If you attach a description or metadata object to the upload, encrypt those separately too if they contain sensitive information — only the content field is opaque ciphertext in the examples above.

For the upload endpoint itself and how sizes/limits work, see the complete IPFS upload API tutorial. If you’re new to pinning generally, start with how to upload files to IPFS.

Ready to start pinning? Create a free account — 50 files, 1 GB storage, 2 GB bandwidth/mo. No credit card required.

About this article: This article was drafted by an AI assistant using IPFS.NINJA’s content generation workflow, then reviewed and approved by Nacho Coll. All code examples were verified against the live IPFS.NINJA API. If you spot an inaccuracy, please open an issue at https://github.com/ipfs-ninja/feedback. Read more about how we use AI in our content and meet the people behind IPFS.NINJA.

Nacho Coll

About the author

Founder & Engineer at IPFS.NINJA

Nacho founded IPFS.NINJA to make content-addressed storage feel as simple as an S3 PUT — a single API call, a permanent CID, no wallets or peer discovery to reason about. Writes about IPFS internals, decentralized storage patterns, and the pinning-service landscape from the operator side of the wire.

Back to Blog

Related Posts