Securing self-hosted agent builders: Dify, Langflow, and Flowise
How-to
Dify, Langflow, and Flowise are the same product from a secrets standpoint. A web UI where you paste provider keys, a database where those keys are stored encrypted, a compose file that holds the encryption key and the database password, and a runtime that hands real values to whatever node a flow calls. A flow with an agent node and an HTTP tool is an agent with every credential the instance knows.
In April 2025 Langflow shipped a fix for CVE-2025-3248: an endpoint that ran
submitted Python through exec() with no authentication, CVSS 9.8, exploited
in the wild and used to build a botnet. A second remote code execution
followed in 2026. That is one project, but the shape is common to the category:
a Python or Node process on the internet, with a code-execution feature by
design, holding every key in its environment. When one of these is popped,
the attacker's first move is env.
This post covers the two layers that decide what env returns: the compose
file and the flows.
Layer 1: the compose file
All three ship a docker-compose.yml that reads a .env. The names differ,
the roles do not:
| Encrypts stored credentials | Database | Admin login | |
|---|---|---|---|
| Dify | SECRET_KEY | DB_PASSWORD, REDIS_PASSWORD | first-run setup |
| Langflow | LANGFLOW_SECRET_KEY | LANGFLOW_DATABASE_URL | LANGFLOW_SUPERUSER, LANGFLOW_SUPERUSER_PASSWORD |
| Flowise | FLOWISE_SECRETKEY_OVERWRITE | DATABASE_PASSWORD | FLOWISE_USERNAME, FLOWISE_PASSWORD |
The first column is the one to care about. It is what stands between a database dump and every provider key anyone ever pasted into the UI. Langflow and Flowise generate one on first start if you do not set it, and write it to the data volume, where it survives every backup of that volume.
Import the .env into seekrit, mint a token, and delete the file. Because
these compose files already use ${VAR} interpolation, the simplest shape
needs no image changes:
seekrit secrets import docker/.env --app dify --env production
seekrit token create --app dify --env production --name dify-host
rm docker/.env
SEEKRIT_TOKEN=skt_… seekrit run -- docker compose up -d
seekrit run resolves the environment, decrypts on the host, and runs
docker compose with the values present, so every ${SECRET_KEY} fills in.
The values are in the container's environment afterwards and in
docker compose config, which is the same exposure as before minus the file on
disk. The Compose post has the entrypoint shape
for putting the resolver inside each image instead, so only a token appears in
docker inspect. With a stack of five services, the interpolation shape is
usually the right trade.
If you set the encryption key for the first time here, do it before the first start. Changing it after credentials exist makes them undecryptable in all three products.
Layer 2: the flows
Provider keys pasted into the UI are stored in the database, encrypted with
the key above, and decrypted for every flow that uses them. Whichever node
runs, the value is in the process. The seekrit change is to store a
placeholder in the UI instead of a key and run
seekrit-proxy beside the stack.
All three products let a model node use an OpenAI-compatible provider with a custom base URL. Set the base URL to the proxy and the key to a placeholder:
| Where | Base URL | API key | |
|---|---|---|---|
| Dify | Settings, Model Provider, OpenAI-API-compatible | http://seekrit-proxy:8080/openai/v1 | {{seekrit:OPENAI_API_KEY}} |
| Langflow | OpenAI component, OpenAI API Base | http://seekrit-proxy:8080/openai/v1 | {{seekrit:OPENAI_API_KEY}} |
| Flowise | ChatOpenAI node, Base Path | http://seekrit-proxy:8080/openai/v1 | {{seekrit:OPENAI_API_KEY}} |
The sidecar:
seekrit proxy compose --preset openai --host api.stripe.com=STRIPE_KEY > compose.proxy.yaml
services:
seekrit-proxy:
image: seekritdev/proxy
environment:
SEEKRIT_TOKEN: ${SEEKRIT_PROXY_TOKEN}
volumes: ["./seekrit-proxy.toml:/seekrit-proxy.toml"]
command: ["--listen", "0.0.0.0:8080"]
The proxy publishes no ports. It is reachable by service name from the other containers and from nowhere else. Its token is bound to an environment that holds the provider and tool keys, and that environment is a different one from the compose-file environment in layer 1, so the builder's own token cannot resolve a provider key.
Now the database holds {{seekrit:OPENAI_API_KEY}}. A dump, an exported flow
with embedded credentials, or an agent node asked to print its key yields that
string. It works only through the proxy, only toward api.openai.com, and only
for the paths and methods the route allows.
Tool credentials follow the same shape. An HTTP request node calling Stripe
gets an Authorization: Bearer {{seekrit:STRIPE_KEY}} header and a URL under
http://seekrit-proxy:8080/stripe/, and the route bounds it:
[[route]]
prefix = "/stripe"
upstream = "https://api.stripe.com"
allow = ["STRIPE_KEY"]
methods = ["GET"]
paths = ["/v1/charges", "/v1/charges/*"]
An agent that was talked into refunding a charge sends a POST and is
refused before it leaves the compose network, with the rule named in the
response.
Two product-specific notes:
- Langflow imports environment variables as global variables by default
(
LANGFLOW_STORE_ENVIRONMENT_VARIABLES, withLANGFLOW_VARIABLES_TO_GET_FROM_ENVIRONMENTnaming which). With layer 1 in place, that would turn every value in the seekrit environment into a global variable available to every flow. Set the list explicitly to the non-secret names you want flows to see, or set the first variable tofalse. - Flowise's credential UI shows a shortened fake value for stored keys. That is a display protection, not a storage one. The stored value is still the full key, encrypted with the key from layer 1, and a placeholder is still the thing to store.
The part no proxy fixes
An instance on the public internet with a code-execution feature is an
instance that will eventually run someone else's code. Keep Langflow above
1.3.0 and current. Put the editor behind authentication that is not the
product's own login, or off the internet entirely. Set LANGFLOW_AUTO_LOGIN to
false. Disable Dify's and Flowise's code nodes for users who do not need
them. Restrict who can edit flows, because whoever can edit a flow can point a
node at any URL, and the proxy will refuse the key but cannot refuse the
request going somewhere else without it.
Then plant a honey token where the old .env
was, and one as a provider credential in the UI with a name that looks real.
Nothing legitimate uses either. A trip means the file or the database was read.
Checklist
- Import
.envinto seekrit, delete it, run compose throughseekrit run. - Set the encryption key deliberately, before first start.
- Run the proxy as a sidecar with its own token and its own environment.
- Replace every provider credential in the UI with a placeholder and the proxy's base URL.
- Route tool credentials the same way, with
methodsandpaths. - Langflow: narrow
LANGFLOW_VARIABLES_TO_GET_FROM_ENVIRONMENT. - Patch, put the editor behind your own authentication, and plant a honey token.
The agent proxy guide has the full route syntax, including the forward mode for products that do not expose a base URL field.