How encrypted secrets work in Rezee pipelines
The full path of a CI secret - AES-256-GCM at rest, names-only listing, decryption only at dispatch over an internal channel, Docker env injection that stays out of inspect, and log redaction with honest caveats.
May 26, 2026 · 4 min read · Kash Gohil
Deploy steps need credentials, and CI systems are where credentials go to leak - into YAML, into logs, into docker inspect. This post walks the full path of a secret through Rezee's pipeline system: how it's stored, when it's decrypted, how it reaches a build container, and how it's kept out of the logs - including the caveats, because secret handling is exactly the topic where marketing prose gets people hurt.
How are secrets stored?
Per repository, as rows of (name, encrypted value) - names constrained to valid shell identifiers, since that's what they'll become. Values are encrypted with AES-256-GCM, an authenticated mode, so a tampered ciphertext fails loudly rather than decrypting to garbage:
export function encryptSecret(plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key(), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv.toString("base64"), tag.toString("base64"), ciphertext.toString("base64")].join(":");
}The stored form is iv:tag:ciphertext - a fresh random 12-byte IV per encryption, the GCM auth tag alongside. The key is a single 32-byte master key supplied by environment at boot; the service refuses to start with a malformed one. (One master key, not per-repo keys - more on that honestly below.)
The management API is written to make the safe path the only path: setting a secret is an upsert, listing returns names only - there is no endpoint that returns a stored value to any user, owner included. Once a value goes in, the only thing that ever reads it out is a pipeline run.
When is a secret decrypted?
At the last responsible moment: dispatch. When the runner polls for pending work, the API decrypts the repo's secrets and includes them in the dispatch response (the claim mechanics behind that poll, benchmarked) - which travels only over the internal service channel, gated by a shared secret header, never through any user-facing endpoint. Decrypted values exist in memory in exactly two places: the API during dispatch, and the runner during the run.
How do secrets reach the build container?
As environment variables - with one detail worth stealing. The runner doesn't pass values on the Docker command line; it passes names:
for _, name := range secretNames {
args = append(args, "-e", name)
}docker run -e NAME (no =value) tells Docker to forward the variable from the runner's own environment. Values therefore never appear in the container's argv, in shell history, or in docker inspect output - places secrets classically end up because -e NAME=value is the obvious thing to type. Inside your step, $DEPLOY_TOKEN just exists; nothing to declare in the YAML.
How are logs kept clean?
Every log line is redacted in the runner, before transmission: each secret value literal-replaced with ***. Order matters here - by the time a line reaches the log pipeline, the API, the database, or a browser, the plaintext never existed downstream. A printenv in your build step ships DEPLOY_TOKEN=*** to storage, not a token that was scrubbed later.
And the caveats, stated plainly, because redaction is a seatbelt and not a forcefield: matching is literal substring only - a secret your script base64-encodes, URL-encodes, or prints in pieces will not be caught; and only values from the secrets store are known to the redactor - a credential you hard-coded somewhere is invisible to it. The rule redaction doesn't replace: treat any secret that ever appeared in a log as burned, and rotate it.
What would we change, and what should you take?
This post described a single master key, and said the honest next step was envelope encryption with per-repo data keys. That has since shipped: each repository has a data key of its own, secrets are sealed under it, and the data key is stored sealed under the master key. The two rotations that used to be one impossible operation are now separate and routine - rezee secrets rotate mints a new data key for one repository and re-encrypts its secrets in place, and rotating the master key re-wraps one small row per repository without reading a secret value at all.
The portable checklist for any CI system, ours included: secrets encrypted at rest with an authenticated mode; no API that returns values; decryption only at dispatch, only over internal channels; injection by name, never argv; redaction in the runner, not the storage layer; and rotation as a habit, not an incident response.
FAQ
Can I read a secret back out of Rezee?
No - by design there is no read-back endpoint for anyone, including the repo owner. Listing shows names and timestamps only. If you lose a value, you set a new one; if that feels inconvenient, it's the inconvenience doing its job.
Are secrets scoped per repository?
Yes - secrets belong to a repo and are injected only into that repo's pipeline runs, managed by the repo owner. All of a repo's secrets are available to its steps as environment variables.
Does log masking make it safe to print secrets?
No. Masking catches verbatim occurrences of stored values - it will not catch encoded, split, or transformed forms. Treat masking as a safety net for accidents, and anything that hit a log as compromised.
Why AES-256-GCM specifically?
Authenticated encryption in one primitive: confidentiality plus integrity, so a modified ciphertext is rejected rather than silently decrypted wrong. It's the standard, boring, correct choice - which is rather the house style.