Skip to content

Working with ejson — Encrypted Secrets That Don't Keep You Up at Night

TL;DR

  1. Install ejson via Homebrew: brew install ejson.
  2. Encrypt: ejson encrypt data/prod_ezproxy-domains.json
  3. Decrypt: You don't need to! CI handles decryption automatically. If you really need to, trigger the Regenerate EZProxy URL workflow and grab the decrypted information from the signed URL.
  4. Never commit unencrypted secrets — ejson files (.json) live safely in the repo; private keys do not.
  5. In CI, the private key is injected as a GitHub Actions secret — you don't need to do anything extra.

Disclaimer and Intended Audience

This guide is internal documentation for the UAS team engineers. It covers how and why we use ejson to manage encrypted secrets in the platform-sso-services repository.

This document is intended for:

  • Developers working on platform-sso-services who need to add, update, or decrypt secrets locally.
  • Maintainers setting up or debugging CI/CD workflows that rely on encrypted configuration files.
  • Anyone who has just opened data/prod_ezproxy-domains.json, seen the EJ[1:...] blobs, and thought "what on earth am I looking at?"

To follow this guide you will need:

  • Access to the platform-sso-services GitHub repository.
  • Access to the shared Bitwarden vault to retrieve the ejson keypair.
  • brew installed on your Mac.

So, What Is ejson, and Why Do We Use It?

Imagine if you will this scenario: you have a JSON file full of shared secrets that your application needs at runtime. You need that file in the repository so deployments are reproducible — but plaintext secrets in git are a one way ticket to a very bad day.

Enter ejson. Developed by Shopify, ejson is a small command-line tool that encrypts the values in a JSON file using public-key cryptography (specifically, NaCl's box construct, which combines X25519 Diffie-Hellman and XSalsa20-Poly1305). The result is a file that:

  • Can be committed to git safely — only the encrypted values live in the repo.
  • Can be encrypted by anyone — the public key is embedded in the file itself, so no private key is needed to add new secrets.
  • Can only be decrypted by whoever holds the private key — which in our case is CI and a small group of engineers with access to Bitwarden.

In our case, data/prod_ezproxy-domains.json holds the per-customer shared secrets for the EZProxy/OverDrive integration. Every value that should be private starts without an underscore (_) prefix — that is ejson's convention for marking a field as "please encrypt this". Fields prefixed with _ are left in plaintext (they are considered metadata, not secrets).

{
  "_public_key": "xXXxXXXXXXXXXxxxXXXXxXxXxxXXxxxXxXxXxxXxxxxxxXXxxxxxxxxXxxxXxxxX",
  "data": [
    {
      "_domain": "example.edu",
      "_domains": ["lib.example.edu"],
      "_customerName": "Example University",
      "secret": "EJ[1:SwmS...redacted...u0BXgzzU2==]",
      "_groupAccountId": 123456
    }
  ]
}

Notice how _domain, _domains, _customerName, and _groupAccountId are in plaintext while secret is an opaque ciphertext blob. That is ejson working exactly as intended.


Act I: Installation and Setup (The "Getting Ready to Rumble" Chapter)

Installing ejson on macOS

ejson is available via Homebrew. One command and you're done:

brew install ejson

Verify it worked:

ejson --version
# ejson 1.5.4

Getting the Private Key (This Is the Important Part)

ejson uses keypairs. The public key lives in the JSON file (and is safe to share). The private key lives on disk in a special directory and must never be committed to git.

The keypair for platform-sso-services is stored in our shared Bitwarden vault.

Steps:

  1. Open Bitwarden and search for the corresponding keyset:
  2. You will find two fields:
    • Public key — the 64-character hex string that matches _public_key in the JSON files.
    • Private key — the secret value you need to place on disk.
  3. Create the ejson keys directory if it doesn't exist:

    sudo mkdir -p /opt/ejson/keys
    
  4. Create a file named after the public key fingerprint and paste the private key into it:

    # Replace <PUBLIC_KEY> with the actual 64-character hex string from Bitwarden
    sudo bash -c 'echo "PRIVATE_KEY_VALUE_FROM_BITWARDEN" > /opt/ejson/keys/<PUBLIC_KEY>'
    sudo chmod 400 /opt/ejson/keys/<PUBLIC_KEY>
    

    For example, for prod_ezproxy-domains.json the public key is xXXxXXXXXXXXXxxxXXXXxXxXxxXXxxxXxXxXxxXxxxxxxXXxxxxxxxxXxxxXxxxX, so the file would be:

    /opt/ejson/keys/xXXxXXXXXXXXXxxxXXXXxXxXxxXXxxxXxXxXxxXxxxxxxXXxxxxxxxxXxxxXxxxX
    
  5. Verify the key is in place:

    ls -la /opt/ejson/keys/
    

🔒 Security note: Treat the private key with the same care as an SSH private key or a password. Do not share it in MS Teams, email, or any unencrypted channel. Bitwarden is the source of truth.


Act II: Day-to-Day Operations (The "Now What Do I Do With This Thing?" Chapter)

Encrypting a File

Once you have added a new plaintext value to the JSON file (for example, a new customer entry with a plaintext secret field), encrypt the file with:

ejson encrypt data/prod_ezproxy-domains.json

ejson reads the _public_key from the file and uses it to encrypt every unencrypted value in place. The file is overwritten with the encrypted version, ready to be committed.

💡 Key insight: Encryption only requires the public key, which is already embedded in the file. Anyone on the team can encrypt — no private key needed.

Decrypting a File

In 99,9% of the cases, you would not need to decrypt the file manually — CI handles that for you.

But if you do need to see a plaintext value (for example, to debug a customer issue), just head to the regenerate-ezproxy-url.yml action, trigger it, wait until it is done, and grab the signed URL from the summary. The information you need would be available in the file downloaded from the signed URL.

⚠️ Remember to wash, flush, and hush your mess — once you are done with the decrypted data, make sure to securely delete any files with sensitive information and avoid leaving secrets in terminal history or logs.

If you absolutely, desperately feel you must decrypt the whole file, talk to the team first. We will be quite curious to understand why you need to do that and if there is a way to avoid it.

The ejson Convention: Underscore Prefix = Plaintext

ejson's rule is simple:

Field name starts with _ Stored as
Yes (e.g. _domain) Plaintext
No (e.g. secret) Encrypted

Use underscores for metadata (domains, names, IDs) and leave secrets without the prefix so ejson encrypts them automatically.


Act III: The Automated Path — How CI Handles This (No Private Key Required, Human)

The real beauty of ejson in our setup is that you rarely need to decrypt manually. Our GitHub Actions workflows take care of it. Here's the full picture:

sequenceDiagram
    actor Engineer
    participant GitHub Actions
    participant ejson
    participant prod_ezproxy-domains.json
    participant AWS SSM / Secrets

    Engineer->>GitHub Actions: Trigger "Add EZProxy Customer" workflow
    GitHub Actions->>ejson: Install ejson (verified checksum)
    GitHub Actions->>AWS SSM / Secrets: Assume IAM role via OIDC
    GitHub Actions->>prod_ezproxy-domains.json: Run add-ezproxy-customer.ts
    Note over prod_ezproxy-domains.json: New entry added with plaintext secret
    GitHub Actions->>ejson: ejson encrypt prod_ezproxy-domains.json
    Note over prod_ezproxy-domains.json: Secret is now encrypted in the file
    GitHub Actions->>GitHub Actions: Open Pull Request with encrypted file
    Engineer->>GitHub Actions: Review & merge PR

The "Add EZProxy Customer" Workflow

The add-ezproxy-customer.yml workflow is the main automation that adds new customers. Its ejson-relevant steps are:

1. Install ejson with checksum verification

The workflow downloads ejson from GitHub Releases and verifies the SHA256 checksum before installing — no homebrew, no trust on first use risks:

- name: Install ejson
  run: |
    EJSON_VERSION="1.5.4"
    EJSON_URL="https://github.com/Shopify/ejson/releases/download/v${EJSON_VERSION}/ejson_${EJSON_VERSION}_linux_amd64.tar.gz"
    EJSON_SHA256="2bb8300cf4f3cd41f56493e4277d2321eaab4f08208a5046e1b62a05337c3aee"

    wget -q "$EJSON_URL" -O ejson.tar.gz
    echo "${EJSON_SHA256}  ejson.tar.gz" | sha256sum -c -
    tar -xzf ejson.tar.gz
    sudo mv ejson /usr/local/bin/

2. Run add-ezproxy-customer.ts

The TypeScript script add-ezproxy-customer.ts handles the full lifecycle:

  • Validates ejson is available before touching any file (fail fast to avoid leaking plaintext).
  • Generates a cryptographically random 32-byte secret (randomBytes(32).toString("base64")).
  • Appends the new entry to prod_ezproxy-domains.json with the plaintext secret.
  • Sorts entries alphabetically to minimize future merge conflicts.
  • Formats the file with Prettier.
  • Runs ejson encrypt to encrypt the secret in place.
  • Outputs the plaintext secret only to stdout (masked immediately in the GitHub Actions log).
flowchart TD
    A[Start: add-ezproxy-customer.ts] --> B{ejson available?}
    B -- No --> C[Exit with error]
    B -- Yes --> D[Parse CLI args]
    D --> E[Read prod_ezproxy-domains.json]
    E --> F{Domain already exists?}
    F -- Yes --> G[Exit: duplicate domain]
    F -- No --> H[Generate random 32-byte secret]
    H --> I[Append new entry with plaintext secret]
    I --> J[Sort entries alphabetically]
    J --> K[Write file]
    K --> L[Format with Prettier]
    L --> M[ejson encrypt]
    M --> N[Output SECRET= to stdout]
    N --> O[Done]

3. The secret never touches the PR

The plaintext secret is captured from stdout, immediately masked in GitHub Actions logs (::add-mask::), stored in a temp file with chmod 600, and then used to generate a CloudFront signed download URL for the OverDrive directive file. The temp file is securely deleted with shred after use. The PR only contains the encrypted prod_ezproxy-domains.json.

The "Regenerate EZProxy URL" Workflow

The regenerate-ezproxy-url.yml workflow re-generates a CloudFront signed URL for an existing customer (for example, when the previous 7-day link expired before the customer could retrieve their directive file).

It follows the same ejson installation pattern — download, verify checksum, install — and then uses the private key (available to the prod environment via GitHub Secrets) to decrypt prod_ezproxy-domains.json and retrieve the existing secret for the given domain.

Why is the private key available in CI but not in the workflow YAML? The private key is stored as a GitHub Actions environment secret (in the prod environment), accessible only to workflows that explicitly target that environment. It is never echoed or logged.


Appendix: Quick Reference

Task Command
Install ejson brew install ejson
Encrypt a file ejson encrypt <file>.json
Decrypt to stdout ejson decrypt <file>.json
Show ejson version ejson --version
Generate a new keypair ejson keygen
Private key location (local) /opt/ejson/keys/<public_key_fingerprint>

The Golden Rules

  1. The private key stays off git. Always. No exceptions.
  2. Encryption is public-key — anyone can encrypt. Only the private key holder can decrypt.
  3. Underscore prefix = plaintext. No underscore = encrypted by ejson.
  4. Decrypt to stdout, not to file. ejson decrypt file.json — never > file.json.
  5. CI handles decryption automatically. The private key is injected via GitHub environment secrets.
  6. Adding new secrets doesn’t require decrypting the file. Encryption is idempotent and only touches values that aren’t already valid EJSON ciphertext.