Skip to content

Use JOSE-encrypted credentials locally and Infisical in cloud#492

Open
jrhizor wants to merge 6 commits into
mainfrom
jrhizor/db-secret-storage
Open

Use JOSE-encrypted credentials locally and Infisical in cloud#492
jrhizor wants to merge 6 commits into
mainfrom
jrhizor/db-secret-storage

Conversation

@jrhizor

@jrhizor jrhizor commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

This PR adds a provider-credential lifecycle for both self-hosted deployments and Elmo Cloud. It is additive: nobody is moved off environment variables here. With no stored credentials, every deployment behaves exactly as it does today, and getCredential falls through to process.env.

Self-hosted deployments

  • Stores complete provider credential bundles in provider_credentials as compact JWE.
  • Uses jose with direct key management (dir) and A256GCM; application code no longer implements the cryptographic primitive. jose is already in the tree at this exact version via better-auth, so this adds no new dependency.
  • Binds each ciphertext to its provider in the authenticated JWE header, so a row cannot be re-homed onto another provider by someone with database write access.
  • Generates ELMO_ENCRYPTION_KEY during CLI initialization and backfills it during upgrades.
  • Falls back to environment variables when no stored credential is available.

Elmo Cloud

The two runtimes read the same Infisical folder differently, because they have different shapes.

Worker (long-lived VM) authenticates once with the official @infisical/sdk using Universal Auth and refreshes every minute, so a rotation lands without a deploy. It reads a single folder rather than recursing from the project root, so the machine identity needs no broader grant and two folders cannot both define OPENAI_API_KEY with no defined winner.

Web app (Vercel serverless) reads provider credentials from its environment, which Infisical's Vercel secret sync populates at deploy time. Reading Infisical at runtime would add an authentication round trip to every cold start and put Infisical's availability in front of the whole site, to serve credentials the request path barely uses. Rotations reach the web app on the next deploy.

Managed-cloud provider credentials never enter the Elmo database.

Required cloud configuration. Only the worker reads these at runtime, but they are required across cloud so a deployment cannot go out with a worker that has no way to reach Infisical:

  • INFISICAL_CLIENT_ID
  • INFISICAL_CLIENT_SECRET
  • INFISICAL_PROJECT_ID
  • INFISICAL_ENVIRONMENT

There is nothing else to configure. Credentials are read from the root of that environment on Infisical's US cloud, non-recursively — neither the path nor the site URL is overridable, so there is no way to point the loader somewhere the credentials are not.

Failure behavior

Provider bundles are applied atomically, so multi-field credentials such as Oxylabs and DataForSEO cannot combine one managed value with an unrelated environment value.

Credentials disappearing is treated as a fault, not as a fallback:

  • An Infisical read that succeeds but returns no provider credentials is an outage — a revoked grant, or a path or environment slug that no longer resolves. The loader throws, the previous values stay in place, and the worker refuses to start if the very first load fails.
  • A refresh failure keeps the last good values and logs an error.
  • A wrong-length ELMO_ENCRYPTION_KEY, a row that will not decrypt, an incomplete bundle, or a managed credential that vanishes each log an error naming the action to take. Nothing in this path fails silently.

Verification

  • pnpm test — 574 tests passed
  • Web, worker, and CLI typechecks passed
  • Deployment-mode smoke checks passed for local, demo, whitelabel, and cloud
  • Exercised end to end: JWE header shape, cross-provider replay rejection, and an empty Infisical read leaving the previous credentials intact

@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
elmo Ready Ready Preview, Comment Jul 25, 2026 8:55pm

Request Review

@jrhizor jrhizor changed the title Store provider credentials encrypted in the DB (dormant) Use JOSE-encrypted credentials locally and Infisical in cloud Jul 23, 2026
Base automatically changed from jrhizor/provider-spend-caps to main July 24, 2026 09:13
Fail loudly instead of silently unconfiguring providers. An Infisical read
that succeeds but returns nothing is an outage, not an empty credential set,
so the loader throws and the overlay keeps its last good values. Misread keys,
undecryptable rows and vanished credentials log errors with the action to take.

Take Infisical off the web request path. The cloud web app runs on Vercel
serverless, where a runtime read would add an authentication round trip to
every cold start and put Infisical in front of the whole site; Infisical's
Vercel secret sync populates the environment at deploy time instead. Only the
worker, which is long-lived and spends against these credentials, keeps the
SDK loader — so the Infisical vars are no longer web startup requirements.

Read one Infisical folder rather than recursing from the project root, so the
machine identity needs no broader grant and two folders cannot both define
OPENAI_API_KEY with no defined winner.

Drop what the payload does not need: typ and v leave the JWE header, since ctx
is what stops a ciphertext being re-homed onto another provider. Drop the hint
column, which stored the last four characters of the longest value in a
bundle — the password, for Oxylabs and DataForSEO — in plaintext beside the
ciphertext, and which nothing reads.

Delete the unused dataforseo client that captured credentials from the
environment at module load.
The worker loads credentials before pg-boss starts, so a source that throws
took the whole worker down. On an upgrade that source is a fresh query against
provider_credentials, which fails if the database is briefly unreachable or not
yet migrated — and the compose file only guarantees migration ordering when
Postgres runs in Docker.

Schedule the retry interval before the first load and let self-hosted modes
carry on with their .env credentials when that load fails. Managed cloud still
rethrows, since it has no environment fallback and a worker that started anyway
would only take jobs it cannot run. This also fixes the web path never retrying
after a failed initial load.
Only the worker reads them at runtime, but treating them as worker-only let a
cloud deployment go out with a worker that had no way to reach Infisical. Make
them cloud requirements again so that misconfiguration surfaces at startup.

INFISICAL_SECRET_PATH and INFISICAL_SITE_URL stay optional — both have working
defaults, and requiring the site URL would break any deployment relying on the
US cloud default.
@infisical/sdk depends on @aws-sdk/credential-providers and @smithy/*, and Nitro
traces a dynamic import into the output whether or not the branch can run. The
cloud web app never loads credentials over the network, so that was megabytes of
AWS SDK shipped into a Vercel serverless function that would never call it.

Move the worker's source selection into its own entry point. startCredentialRefresh
now takes a source and whether it is required, so the module the web app imports
only ever references instanceCredentialSource.

Tracing the import graph from apps/web/src/server.ts no longer reaches
@infisical/sdk; from apps/worker/src/index.ts it still does.
Both only ever held their defaults, and each was another way for a cloud
deployment to point the loader somewhere the credentials are not. Read the root
of INFISICAL_ENVIRONMENT on Infisical's US cloud, with no way to override
either.

With the read already non-recursive, that fixes the folder contract in one
place: one flat set of canonically named secrets at the environment root, and
nothing else in the project is fetched.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant