Loading...
Ababil Hossain
Ababil Hossain

Self-taught Software Developer | Full-Stack, DevTools & Open Source

How I Designed the Architecture of EnvLink

How I Designed the Architecture of EnvLink

The decisions and tradeoffs behind building an end-to-end encrypted environment file sharing tool.

So I built this tool called EnvLink, and I want to walk you through how I implemented the encryption part. Not because I think it’s groundbreaking or anything - it’s mostly just standard crypto primitives wired together properly - but because I learned a ton building it and made some interesting decisions along the way.

The Core Problem I Was Solving

Here’s the thing: I was tired of seeing people share.envfiles on Slack or Discord. Database URLs with passwords in them, API keys, AWS credentials - just sitting there in chat history forever.

I wanted to build something where:

  1. You could quickly share env files with your team
  2. The server hosting it couldn’t read your secrets (even if it wanted to)
  3. Links would auto-expire so old credentials don’t float around forever

That third requirement meant I needed a server. But the second requirement meant the server couldn’t be trusted with plaintext. So, client-side encryption it is.

Two Modes: Password vs No Password

Early on, I had to make a choice. Do I always require passwords, or do I make it optional?

I went with both. Here’s why:

Password-protected mode is for production database URLs, API keys you really care about, stuff you’re sharing with your actual team. Someone needs to know the password to decrypt.

Optional-password mode (which I added later) is for when you just want to quickly share staging configs or when you’re onboarding someone and don’t want them to fumble with copying a password. The decryption key is embedded in the extended ID itself (not as a URL parameter).

The optional-password mode embeds the access key directly in the ID formatel_{baseId}{accessKey}. It’s still encrypted in transit and at rest, and most importantly, the EnvLink server still can’t read it because the access key never gets sent to the server. I figured if you’re using it for low-stakes stuff, the convenience is worth it.

Here’s how the two modes differ architecturally:

graph TD
    subgraph pw["Password-Protected Mode"]
        A1[.env Files] --> B1[Password Input]
        B1 --> C1[Hash Password]
        C1 --> D1[Encrypt Files]
        D1 --> E1[Send Encrypted + Hash]
        E1 --> F1[Server Storage]
        F1 --> G1[Return Encrypted Data]
        G1 --> H1[Decrypt with Password]
        H1 --> I1[.env Files Out]
    end

    subgraph op["Optional-Password Mode"]
        A2[.env Files] --> B2[Generate Access Key]
        B2 --> C2[Encrypt Files]
        C2 --> D2[Send Encrypted Only]
        D2 --> E2[Server Storage]
        B2 --> F2[Embed in Extended ID]
        E2 --> G2[Return Encrypted Data]
        F2 --> H2[Extract Key from ID]
        G2 --> I2[Decrypt with Key]
        H2 --> I2
        I2 --> J2[.env Files Out]
    end

The Encryption Stack

I went with AES-256-GCM because:

  • It’s fast
  • It’s battle-tested
  • It gives you authenticated encryption (the GCM part)

That last point is important. With GCM mode, if anyone tampers with the encrypted data, decryption fails. You can’t just flip some bits and hope it works.

For password-based key derivation, I used PBKDF2 with 100,000 iterations. I know some people prefer Argon2 or scrypt, but PBKDF2 is in Node’s crypto module out of the box, and 100k iterations is expensive enough to make brute-forcing painful.

First, here are the crypto constants:

JAVASCRIPT
const CRYPTO_CONFIG = {
  ALGORITHM: "aes-256-gcm",
  DIGEST: "sha256",
  KEY_LENGTH: 32,
  IV_LENGTH: 16,
  ITERATIONS: 100000,
};

View on GitHub

Here’s the key derivation function:

JAVASCRIPT
const deriveKey = async (password, salt) => {
  const pbkdf2 = util.promisify(crypto.pbkdf2);
  return pbkdf2(
    password,
    salt,
    CRYPTO_CONFIG.ITERATIONS,
    CRYPTO_CONFIG.KEY_LENGTH,
    CRYPTO_CONFIG.DIGEST,
  );
};

View on GitHub

This takes a password and salt, runs 100,000 iterations of PBKDF2 with SHA-256, and outputs a 32-byte (256-bit) key. That’s what we use for AES-256-GCM encryption.

Here’s my encryption function:

JAVASCRIPT
export const encrypt = async (plaintext, password) => {
  const salt = crypto.randomBytes(CRYPTO_CONFIG.KEY_LENGTH);
  const key = await deriveKey(password, salt);
  const iv = crypto.randomBytes(CRYPTO_CONFIG.IV_LENGTH);

  const cipher = crypto.createCipheriv(CRYPTO_CONFIG.ALGORITHM, key, iv);

  const encrypted = Buffer.concat([
    cipher.update(plaintext, "utf8"),
    cipher.final(),
  ]);

  return {
    encryptedData: encrypted.toString("base64"),
    iv: iv.toString("base64"),
    salt: salt.toString("base64"),
    authTag: cipher.getAuthTag().toString("base64"),
  };
};

View in GitHub

The salt and IV are generated randomly every time. This means even if you encrypt the same file with the same password twice, you get completely different output. That’s important because it prevents attackers from learning anything by comparing encrypted files.

The auth tag from GCM is the secret sauce - it proves the data hasn’t been modified.

And here’s the decryption function:

JAVASCRIPT
export const decrypt = async (input, password) => {
  const encryptedData = Buffer.from(input.encryptedData, "base64");
  const iv = Buffer.from(input.iv, "base64");
  const salt = Buffer.from(input.salt, "base64");
  const authTag = Buffer.from(input.authTag, "base64");

  try {
    const key = await deriveKey(password, salt);
    const decipher = crypto.createDecipheriv(CRYPTO_CONFIG.ALGORITHM, key, iv);

    decipher.setAuthTag(authTag);

    const decrypted = Buffer.concat([
      decipher.update(encryptedData),
      decipher.final(),
    ]);

    return decrypted.toString("utf8");
  } catch {
    throw new Error("Invalid password or corrupted data");
  }
};

View in GitHub

The decryption uses the same salt to derive the key, verifies the auth tag (if it doesn’t match, decryption fails), and returns the plaintext. If anything’s wrong with the password or the data has been tampered with, you get an error.

Here’s the complete encryption flow:

sequenceDiagram
    participant User
    participant Client
    participant Crypto
    participant Server

    User->>Client: Provide password & files
    Client->>Crypto: Generate random salt (32 bytes)
    Client->>Crypto: Generate random IV (16 bytes)
    Crypto->>Crypto: PBKDF2(password, salt, 100k iterations)
    Note over Crypto: Derives 256-bit AES key
    Crypto->>Crypto: AES-256-GCM encrypt
    Crypto-->>Client: encrypted data + IV + salt + auth tag
    Client->>Client: Hash password with SHA-256
    Client->>Server: Send encrypted payload + password hash
    Server->>Server: Store everything (no password!)
    Server-->>Client: Return EnvLink ID

Here’s how the actual password-protected creation works in the CLI:

JAVASCRIPT
const encryptedData = JSON.stringify(
  await encrypt(JSON.stringify(files), password),
);

const passwordHash = hashPasswordDeterministic(password);

const requestPayload = {
  encryptedData,
  passwordHash,
  expirationDuration: expiration,
  filesCount: filesToUpload.length,
};

if (reference && reference.trim() !== "") {
  requestPayload.reference = reference;
}

const response = await apiClient.post("/envlinks", requestPayload);

View on GitHub

The key point: the password itself NEVER goes to the server. Only the hash does. And because the encryption happens first with the actual password, the server can’t decrypt even with the hash.

The Zero-Knowledge Proof Thing

For password-protected links, I needed the server to verify that someone knows the correct password, but I couldn’t send the password to the server (because then the server could decrypt the files).

What I ended up doing is a challenge-response type thing:

  1. When you create a link, I hash your password with SHA-256 and send that hash to the server
  2. When you try to access the link, you generate a “proof” by combining that hash with a salt that’s unique to the link
  3. The server does the same calculation and compares the results

View on GitHub

Here’s the actual code:

JAVASCRIPT
export const generateZKProof = async (passwordHash, envlinkId) => {
  const serverSalt = crypto
    .createHash(CRYPTO_CONFIG.DIGEST)
    .update(`envlink:${envlinkId}`)
    .digest("hex");

  const input = passwordHash + serverSalt;

  const key = await deriveKey(input, serverSalt);

  return key.toString("base64");
};

View on GitHub

The important bit: the proof is different for every link (because the link ID is in the salt). So even if someone intercepts a valid proof, they can’t reuse it for a different link.

On the server side, I do the same calculation with the stored password hash and compare:

JAVASCRIPT
const clientProof = Buffer.from(req.body.proof, "base64");
const expectedProof = Buffer.from(
  await generateZKProof(storedHash, linkId),
  "base64",
);

const isValid = crypto.timingSafeEqual(clientProof, expectedProof);

ThattimingSafeEqualcall is crucial. Regular===comparison would leak information through timing - an attacker could measure how long the comparison takes and figure out where the strings differ.timingSafeEqualalways takes the same amount of time regardless of where the difference is.

Here’s the complete zero-knowledge proof flow:

sequenceDiagram
    participant Client
    participant Server

    Note over Client,Server: Creation Phase
    Client->>Client: Hash password (SHA-256)
    Client->>Server: Send passwordHash + encrypted data
    Server->>Server: Store passwordHash (NOT the password!)

    Note over Client,Server: Access Phase
    Client->>Client: Hash password (SHA-256)
    Client->>Client: Generate salt from linkId
    Client->>Client: PBKDF2(passwordHash + salt, salt)
    Client->>Server: Send proof (not password!)

    Server->>Server: Get stored passwordHash
    Server->>Server: Generate same salt from linkId
    Server->>Server: PBKDF2(storedHash + salt, salt)
    Server->>Server: timingSafeEqual(client proof, expected proof)

    alt Proof matches
        Server-->>Client: Return encrypted data
        Client->>Client: Decrypt with original password
    else Proof doesn't match
        Server-->>Client: 401 Unauthorized
    end

Optional-Password Links: A Different Approach

For optional-password links, I took a simpler route.

When you create one, I generate a random 16-character access key using Base62 encoding (0-9, A-Z, a-z). That key is used to encrypt the files, but it never gets sent to the EnvLink server. Instead:

  • Server gets: base ID + encrypted data
  • You get: extended ID that includes the access key

The Base62 encoding function:

JAVASCRIPT
const BASE62_CHARS =
  "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

const generateBase62String = (length) => {
  const randomBytes = crypto.randomBytes(length);
  let result = "";

  for (let i = 0; i < length; i++) {
    const byte = randomBytes[i];
    result += BASE62_CHARS[byte % BASE62_CHARS.length];
  }

  return result;
};

const generateAccessKey = () => generateBase62String(16);

View on GitHub

So a link looks like:el_abc123xyz456accesskey789

The first 19 characters (el_abc123xyz456) go to the server for lookup. The last 16 (accesskey789) stay client-side only and are used for decryption.

JAVASCRIPT
const accessKey = generateAccessKey();

const encryptedData = JSON.stringify(
  await encrypt(JSON.stringify(files), accessKey),
);

const response = await api.post("/envlinks", {
  encryptedData,
  filesCount: files.length,
});

const extendedId = `${response.data.id}${accessKey}`;

View on GitHub

When someone installs it:

JAVASCRIPT
const extendedId = "el_abc123xyz456accesskey789";
const baseId = extendedId.slice(0, 19);
const accessKey = extendedId.slice(19);

const response = await api.get(`/envlinks/${baseId}`);

const decrypted = await decrypt(response.data, accessKey);

View on GitHub

My EnvLink server never sees that access key. It can’t decrypt the files even if it wanted to.

Here’s how optional-password links work:

sequenceDiagram
    participant Client
    participant Server
    participant Recipient

    Note over Client: Creation
    Client->>Client: Generate random baseId (16 chars)
    Client->>Client: Generate random accessKey (16 chars)
    Client->>Client: Encrypt files with accessKey
    Client->>Server: POST baseId + encrypted data
    Note over Server: Stores only baseId + ciphertext
Access key NEVER sent! Server-->>Client: Success Client->>Client: Combine: el_{baseId}{accessKey} Note over Recipient: Installation Recipient->>Recipient: Parse extended ID Recipient->>Recipient: Extract baseId (first 19 chars) Recipient->>Recipient: Extract accessKey (last 16 chars) Recipient->>Server: GET /envlinks/{baseId} Server-->>Recipient: Return encrypted data Recipient->>Recipient: Decrypt with accessKey Recipient->>Recipient: Write .env files

What Actually Gets Stored

I’m paranoid about logging and accidentally exposing stuff, so I was very careful about what hits the database.

For password-protected links:

JAVASCRIPT
{
  id: "el_fWaoSeUJYOttvsq6",
  encryptedData: "{\"encryptedData\":\"AKMyRweoUPy1MObsAth1KAlTyASlfQhUzsrFHxo4dtUHc5AKxe9XWlyWtJgB6tSjilm9n30sviaupHK+53PiyNfpARP270D3jeqER4jjo083AiFngbXJiZq8bfNyt6J/ltQ+Jd4JNhIF6nXdOg3tTtP...\",\"iv\":\"URHszVV7+sVISXMN4e8mHw==\",\"salt\":\"rEy7JdnEgbEkW6PPTLc7tjAoXTEMPZxK3AqcjY3+2ew=\",\"authTag\":\"s9WAZhS0F6Cw9SAorsJPGA==\"}",
  passwordHash: "256abe4a7476c2baaa8907a038dc2b2c28fe3cd6178a43b1911f8fb95343390f",
  filesCount: 3,
  expiresAt: "2026-10-09T04:00:04.227Z",
  reference: "prod-api-keys",
  installCount: 2,
  createdAt: "2026-10-08T04:00:04.228Z",
  updatedAt: "2026-10-08T04:00:35.620Z"
}

View on GitHub

For optional-password links (extended ID):

JAVASCRIPT
{
  id: "el_abc123xyz456",
  encryptedData: "{\"encryptedData\":\"k7jH9mP2xQ8vN5tR3wL6zBpYcXvKnMqAsFdGhJkLmNoP...\",\"iv\":\"a8b3c9d2e1f4g5h6\",\"salt\":\"m4n5o6p7q8r9s0t1u2v3w4x5y6z7a8b9\",\"authTag\":\"x9y8z7a6b5c4d3e2\"}",
  filesCount: 2,
  expiresAt: "2026-10-08T11:30:00.000Z",
  installCount: 0,
  createdAt: "2026-10-08T10:30:00.000Z",
  updatedAt: "2026-10-08T10:30:00.000Z"
}

View on GitHub

No passwords. No access keys. No plaintext files. TheencryptedDatais a JSON string containing the encrypted payload with salt, IV, auth tag, and ciphertext - but it’s all encrypted. Without the correct password (for password-protected) or access key (for optional-password), it’s just random bytes. No way to decrypt anything on the server side.

Here’s what data flows where:

graph TD
    subgraph client["Client Side - Never Leaves Machine"]
        A[Password/Access Key]
        B[Plain .env Files]
        C[Encryption Key]
    end

    subgraph network["Network - Safe to Transmit"]
        D[Encrypted Data]
        E[Password Hash / Base ID]
        F[Salt + IV + Auth Tag]
    end

    subgraph server["Server Side - Safe to Store"]
        G[Database]
        H[Stored: Encrypted Data]
        I[Stored: Password Hash]
        J[Stored: Metadata]
    end

    A --> C
    B --> D
    C --> D

    D --> G
    E --> G
    F --> G

    G --> H
    G --> I
    G --> J

Why I Built It This Way

I could’ve built a simpler version where the server encrypts stuff with its own keys. That’s what most file-sharing services do. But then:

  • I’d be responsible for protecting those keys
  • A breach would expose everything
  • Legal requests could force me to hand over data
  • Users would have to trust me

With zero-knowledge encryption:

  • I literally can’t read your files
  • A breach gets the attacker nothing but encrypted blobs
  • Legal requests are useless (I can’t decrypt what I don’t have keys for)
  • Users don’t have to trust me - the crypto is open source, they can verify

Final Thoughts

Building EnvLink taught me that good security isn’t about using the fanciest algorithms or the newest cryptographic primitives. It’s about:

  1. Clearly defining what you’re protecting and from whom
  2. Using well-tested building blocks correctly
  3. Making the secure path the easy path
  4. Being honest about limitations and tradeoffs

If you’re building something similar, feel free to steal ideas from this. That’s why it’s open source.

Now go encrypt some stuff. 🔐


This is part of my build-in-public series where I write about projects I’m working on. Follow me on GitHub for more.

Advertisement

Join the conversation

Share your thoughts

Loading comments...
Reading Progress 0%

Share this post