Sergen Tanguc
Publication: JUN 2025 Reading time: 6 min

Your Team Is Sharing Secrets Over Slack. Here's the Fix.

#security #devops #secrets #gitops #cryptography #rust

Here’s what secret management looks like at most companies: a new developer joins. Someone from the team opens a DM, pastes the contents of .env.production, and says “don’t share this.” That file contains database passwords, API keys, and service tokens. It now lives in Slack’s servers indefinitely. Maybe the developer’s personal laptop. Maybe their notes app.

This is not a hypothetical. It’s the default workflow at most teams, including teams that know better. The alternatives — a “secrets” section in Notion that’s six months out of date, a shared LastPass vault with one master password everyone knows, a wiki page that someone forgot to delete — are variations on the same problem.

The formal solutions exist. HashiCorp Vault, AWS Secrets Manager, Azure Key Vault. They solve the storage problem but introduce a different one: your application now has a runtime dependency on a network service with its own auth layer, rate limits, failure modes, and VPN requirements. Getting a database password means an HTTP call with retry logic at startup. When Vault is down, your app doesn’t start.

OneSeal is my attempt at a third path. View the project.

The Core Idea

Secrets-as-code is not a new concept. The problem is that existing implementations either mean “committing plaintext .env files to git” (wrong) or “generating unencrypted config files as part of your IaC pipeline” (slightly less wrong but still wrong).

OneSeal’s take: secrets should be a versioned, encrypted, type-safe npm package. You install them like any other dependency. You pin them to a version. You review changes to them in pull requests. You rotate them by publishing a new package version.

The result looks like this:

import { State } from '@contoso/my-infra';
const state = await new State().initialize();
// full TypeScript types — IDE autocomplete works
const dbHost = state.database.postgresql.host;
const dbPass = state.database.postgresql.password; // encrypted at rest
const stripeKey = state.payments.stripe.secretKey;
const redisUrl = state.cache.redis.connectionString;

No process.env.WHATEVER_VARIABLE_NAME_I_THINK_IT_WAS. No undefined-at-runtime failures. No “is it DB_HOST or DATABASE_HOST?” conversations. The types are generated from your actual infrastructure — if a Terraform output doesn’t exist, your code won’t compile.

How It Works

OneSeal is a Rust CLI that takes a Terraform state file as input and generates a TypeScript SDK package as output. During generation, it reads all outputs from the state, detects which ones are marked sensitive = true, and encrypts those using Age (X25519 + ChaCha20) with the team’s public keys. Non-sensitive outputs — URLs, feature flags, resource IDs — are kept plaintext.

Terminal window
# generate-key creates ~/.oneseal/age.key + age.pub
oneseal generate-key
# generate SDK from Terraform state
oneseal generate terraform.tfstate --name @contoso/my-infra
# output is a versioned .tgz at ./oneseal-dist/
# safe to commit to a private repo or push to a private registry

The generated SDK contains the encrypted secrets and the TypeScript interfaces. At runtime, initialize() reads the private key (from ~/.oneseal/age.key locally, or ONESEAL_AGE_PRIVATE_KEY in CI) and decrypts secrets in-memory. Nothing ever touches disk in plaintext.

Multi-Recipient Encryption

This is where it gets interesting for teams. Age supports multi-recipient encryption — the same ciphertext can be decrypted by multiple independent private keys. OneSeal uses this to encrypt each secret for every team member simultaneously.

The workflow: each developer generates a keypair once. Public keys are stored in a shared repository (a company/oneseal-keys repo with one .pub file per person). When generating the SDK, point OneSeal at the keys directory:

Terminal window
oneseal generate terraform.tfstate \
--name @contoso/my-infra \
--public-key-path ./oneseal-keys/

OneSeal discovers all .pub files and encrypts for all of them. Adding a new team member is a pull request to the keys repo and a regeneration of the SDK — no side-channel secret sharing, no Slack DMs with .env files.

CI/CD gets its own key with no human access:

Terminal window
# generate a CI-specific keypair
oneseal generate-key --output ./ci
# ci.key goes to your secret store (never committed)
# ci.pub goes to the keys repo (committed)

The Comparison

vs. environment variables

Env vars are flat, untyped, and discovered at runtime. process.env.DATBASE_URL (typo intentional — this exact bug is in OneSeal’s README as a real example) returns undefined and crashes in production. There is no compile-time validation, no schema, no version history.

OneSeal generates typed interfaces from your actual infrastructure outputs. Rename a Terraform output, regenerate the SDK, and TypeScript tells you every callsite that needs updating — before you ship.

vs. Vault / AWS Secrets Manager

These are excellent storage solutions. The critique isn’t that they’re bad — it’s that making them a runtime dependency adds operational complexity that most teams underestimate. OneSeal’s model: call Vault (or whatever your source is) once during SDK generation in CI. Ship the encrypted result as a package. At runtime, zero network calls, zero external dependencies for secret access.

The two approaches are complementary. Vault stores and rotates secrets. OneSeal distributes them as code.

vs. SOPS

SOPS (Secrets OPerationS, from Mozilla) solves a similar problem in the IaC world: encrypt secrets files for safe git storage. It supports Age, PGP, and AWS KMS. The main difference is the output format. SOPS produces encrypted YAML/JSON/dotenv files — the consumer still needs to parse them, deal with the decryption at the application boundary, and navigate a string-based interface.

OneSeal’s output is a typed SDK. The difference between reading an encrypted YAML file and calling state.database.postgresql.password is the difference between stringly-typed and type-safe.

Practical Workflow

For a team already using Terraform, adopting OneSeal looks like this:

  1. Each developer runs oneseal generate-key once. Public keys go to a shared repo.
  2. A CI job runs oneseal generate after every Terraform apply, generating a new SDK version.
  3. The SDK is committed to a private repository or pushed to a private npm registry.
  4. Applications depend on the SDK package. Developers install it with npm install.
  5. CI environments have ONESEAL_AGE_PRIVATE_KEY in their secret store. That’s the only secret that needs to be managed externally.

The result: secret rotation is a Terraform change plus a CI run. New developer onboarding is npm install. The entire secret schema lives in version control, reviewable in pull requests. No more Slack DMs with .env files.

Current State and Roadmap

OneSeal is at v0.1.0. Terraform state is the only supported input source today. The Rust CLI and TypeScript SDK output are stable. Python, Go, and PHP SDK generation are in progress.

Planned input sources include .env files, Pulumi state, HashiCorp Vault KV, and the major cloud secret managers (AWS, Azure, GCP). The vision is a single tool that can read secrets from anywhere and emit type-safe SDKs in any language.

View project