Secrets and signing keys
Everything the platform needs to authenticate — to your database, your object store, our registry, and to its own users — is supplied through Helm values. That does not mean it belongs in the same file as your hostname.
Three files, and only one of them is committed
Section titled “Three files, and only one of them is committed”Keep configuration, the credentials you supply, and the keys a script generates in separate files, and pass each one:
helm install edisyl <chart> \ -n edisyl --create-namespace \ -f edisyl.values.yaml \ -f secrets.local.yaml \ -f auth-keys.local.yaml \ --timeout 20m| File | Holds | In git |
|---|---|---|
edisyl.values.yaml |
config.domain, ingress hosts, which datastores you bundle, image overrides |
Yes — this is the file you review and diff |
secrets.local.yaml |
Credentials you fill in by hand: DATABASE_URL, Redis, object-storage keys |
No. Add it to .gitignore before you write it |
auth-keys.local.yaml |
The auth signing material, written by the script below | No. Same |
The last two are split for a mechanical reason: the minting script overwrites whatever file it writes to. Pointing it at the file holding your database URL would silently discard it. One file is hand-edited, the other is generated, so they cannot share a name.
The registry password is in none of them — it is a 12-hour ECR token, passed on the install command instead. See Registry access.
Later -f wins. Helm merges values files left to right, so the credentials
file overrides the configuration file where they name the same key. That is
what lets the committed file carry an empty placeholder for a value the
gitignored one fills in.
If you would rather not hold credentials in a file at all, the chart reads them from a secret manager instead — see secrets.provider: external-secrets.
Mint the auth signing material
Section titled “Mint the auth signing material”The chart deliberately generates none of this. Helm cannot produce an EC keypair, and minting a JWT needs HMAC-SHA256 — neither is something a template can do.
You need four things: an ES256 keypair for user tokens, an HS256 secret
behind the anon and service_role keys, those two JWTs themselves, and an
RSA key the auth service validates at config load for SAML.
These keys are yours alone. We do not generate, hold or have any way to recover signing material for a self-hosted deployment — everything below runs on your own machine, and the output never leaves it.
Two ways to produce them. Both write the same file; pick whichever you would rather audit.
With openssl alone
Section titled “With openssl alone”Every step is a single recognisable command, so you can run them one at a time
and look at what each produces. Needs openssl, jq and uuidgen.
# Mints the auth service's signing material into auth-keys.local.yaml.# Run once: re-minting rotates the keys and strands every identity already# written against them. Needs openssl, jq and uuidgen.set -euumask 077b64u() { base64 | tr '+/' '-_' | tr -d '=\n'; }
# The HS256 secret behind the anon and service_role keys (>= 32 chars).SEC=$(openssl rand -hex 24)
# The ES256 keypair for user tokens.openssl ecparam -name prime256v1 -genkey -noout -out ec.pem
# Its JWK coordinates, taken straight out of the DER encoding. Binary stays in# files and pipes, never in a shell variable: a variable cannot hold a NUL byte,# and one dropped byte yields a public key that does not match the private one —# tokens then sign but never verify.openssl ec -in ec.pem -pubout -outform DER 2>/dev/null | tail -c 65 > point.binD=$(openssl ec -in ec.pem -outform DER 2>/dev/null | dd bs=1 skip=7 count=32 2>/dev/null | b64u)X=$(dd if=point.bin bs=1 skip=1 count=32 2>/dev/null | b64u)Y=$(dd if=point.bin bs=1 skip=33 count=32 2>/dev/null | b64u)KID=$(uuidgen | tr 'A-Z' 'a-z')
mkjwt() { h=$(printf '{"alg":"HS256","typ":"JWT"}' | b64u) p=$(printf '%s' "$1" | b64u) s=$(printf '%s' "$h.$p" | openssl dgst -sha256 -hmac "$SEC" -binary | b64u) printf '%s.%s.%s' "$h" "$p" "$s"}NOW=$(date +%s); EXP=$((NOW + 315360000)) # ten yearsANON=$(mkjwt "{\"role\":\"anon\",\"iss\":\"supabase\",\"iat\":$NOW,\"exp\":$EXP}")SVC=$(mkjwt "{\"role\":\"service_role\",\"iss\":\"supabase\",\"iat\":$NOW,\"exp\":$EXP}")
# The key set: the EC key, plus a VERIFY-ONLY HS256 entry. Both are required.# Without the EC key the service issues HS256 tokens with no issuer claim and# every authenticated request 401s. But once a key set exists the service# rejects every algorithm outside it, which would break the service_role token# against its own admin API — hence the second entry, with key_ops keeping the# EC key the sole signer so user tokens stay ES256.JWT_KEYS=$(jq -cn --arg x "$X" --arg y "$Y" --arg d "$D" --arg kid "$KID" \ --arg k "$(printf '%s' "$SEC" | b64u)" \ '[{kty:"EC",crv:"P-256",x:$x,y:$y,d:$d,kid:$kid,alg:"ES256",use:"sig",key_ops:["sign","verify"]}, {kty:"oct",k:$k,alg:"HS256",use:"sig",kid:"legacy-hs256",key_ops:["verify"]}]')
# The RSA key the auth service validates at config load for SAML.SAML=$(openssl genpkey -algorithm rsa -pkeyopt rsa_keygen_bits:2048 -outform DER 2>/dev/null \ | base64 | tr -d '\n')
# Single-quote the key set: it contains [] and {}, which bare YAML would read# as flow collections.cat > auth-keys.local.yaml <<YAML# Real signing material — never commit. Minted $(date -u +%Y-%m-%dT%H:%M:%SZ).secrets: managed: edisyl-auth: data: GOTRUE_JWT_SECRET: $SEC GOTRUE_JWT_KEYS: '$JWT_KEYS' GOTRUE_SAML_PRIVATE_KEY: $SAML edisyl-auth-client: data: AUTH_ANON_KEY: $ANON edisyl-auth-service-key: data: AUTH_SERVICE_KEY: $SVC edisyl-auth-admin: data: AUTH_ADMIN_TOKEN: $SVCYAMLrm -f ec.pem point.bin
# Check it: x, y and d must each decode to exactly 32 bytes. A short one is the# NUL-byte truncation above, and it is invisible by eye.python3 - auth-keys.local.yaml <<'CHECK'import base64, json, sys, yamlks = json.loads(yaml.safe_load(open(sys.argv[1])) ["secrets"]["managed"]["edisyl-auth"]["data"]["GOTRUE_JWT_KEYS"])ec = ks[0]for f in ("x", "y", "d"): n = len(base64.urlsafe_b64decode(ec[f] + "=" * (-len(ec[f]) % 4))) print(f" {f}: {n} bytes", "OK" if n == 32 else "WRONG — regenerate")print(" entries:", [k.get("kid") for k in ks])CHECKecho "wrote auth-keys.local.yaml"Run it with bash mint-auth-keys.sh. It ends by checking its own output —
each of x, y and d must decode to exactly 32 bytes, which is what
catches the NUL-byte truncation the comments warn about, and is invisible by
eye:
x: 32 bytes OK y: 32 bytes OK d: 32 bytes OK entries: ['74c9d29b-…', 'legacy-hs256']Two entries, and 32 bytes three times. Anything else means regenerate.
With python
Section titled “With python”Shorter, and it does the same work in one pass.
umask 077python3 - auth-keys.local.yaml <<'PY'import base64, hashlib, hmac, json, secrets, subprocess, sys, time, uuid
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
def jwt(payload, key): h = b64u(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(',', ':')).encode()) p = b64u(json.dumps(payload, separators=(',', ':')).encode()) s = b64u(hmac.new(key.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()) return f"{h}.{p}.{s}"
sec = secrets.token_urlsafe(48)[:48] # the service requires >= 32 charsnow = int(time.time()); exp = now + 10 * 365 * 24 * 3600anon = jwt({"role": "anon", "iss": "supabase", "iat": now, "exp": exp}, sec)service = jwt({"role": "service_role", "iss": "supabase", "iat": now, "exp": exp}, sec)
# ES256 keypair for USER tokens. The API verifies user JWTs as ES256 against# the auth service's published JWKS and refuses every other algorithm, so the# HMAC secret above covers only the anon/service_role API keys.pem = subprocess.run(["openssl", "ecparam", "-name", "prime256v1", "-genkey", "-noout"], check=True, capture_output=True).stdouttext = subprocess.run(["openssl", "ec", "-text", "-noout"], input=pem, check=True, capture_output=True).stdout.decode()
def hexblock(label): out, grabbing = [], False for line in text.splitlines(): if line.strip().startswith(label): grabbing = True continue if grabbing: if line.startswith((" ", "\t")) and ":" in line: out.append(line.strip()) else: break return bytes.fromhex("".join(out).replace(":", ""))
d, point = hexblock("priv:"), hexblock("pub:")if len(point) != 65 or point[0] != 0x04: sys.exit("unexpected EC public point from openssl (want 65-byte uncompressed)")x, y = point[1:33], point[33:65]d = d[-32:].rjust(32, b"\x00") # openssl may emit a leading pad byte
kid = str(uuid.uuid4())private_jwk = {"kty": "EC", "crv": "P-256", "x": b64u(x), "y": b64u(y), "d": b64u(d), "kid": kid, "alg": "ES256", "use": "sig", "key_ops": ["sign", "verify"]}# Verify-only, and not optional: setting the key set makes the service reject# every algorithm outside it, so without this entry the HS256 service_role key# stops working against its own admin API. key_ops keeps the EC key the sole# signer, so user tokens stay ES256.legacy_verify = {"kty": "oct", "k": b64u(sec.encode()), "alg": "HS256", "use": "sig", "kid": "legacy-hs256", "key_ops": ["verify"]}jwt_keys = json.dumps([private_jwk, legacy_verify], separators=(',', ':'))
saml = subprocess.run(["openssl", "genpkey", "-algorithm", "rsa", "-pkeyopt", "rsa_keygen_bits:2048", "-outform", "DER"], check=True, capture_output=True).stdout
with open(sys.argv[1], "w") as f: f.write("# Minted by hand. Real signing material — never commit.\n") f.write("secrets:\n managed:\n") f.write(" edisyl-auth:\n data:\n") f.write(f" GOTRUE_JWT_SECRET: {sec}\n") # Single-quoted: the JSON contains [] and {}, which bare YAML would read as # flow collections. base64url never contains a single quote. f.write(f" GOTRUE_JWT_KEYS: '{jwt_keys}'\n") f.write(f" GOTRUE_SAML_PRIVATE_KEY: {base64.b64encode(saml).decode()}\n") f.write(" edisyl-auth-client:\n data:\n") f.write(f" AUTH_ANON_KEY: {anon}\n") f.write(" edisyl-auth-service-key:\n data:\n") f.write(f" AUTH_SERVICE_KEY: {service}\n") # service_role is an admin role, so this token reaches the admin surface # used to register an identity provider. f.write(" edisyl-auth-admin:\n data:\n") f.write(f" AUTH_ADMIN_TOKEN: {service}\n")print(f"minted ES256 keypair (kid {kid}), HS256 secret, anon/service_role JWTs, SAML SP key")PYNeeds python3 and openssl. It writes auth-keys.local.yaml in the shape the
chart expects; umask 077 keeps the file readable only by you.
Why the key set has two entries
Section titled “Why the key set has two entries”The trap this avoids is worth stating plainly, because the symptom points nowhere near the cause.
A service configured with only a symmetric secret issues HS256 tokens with no
issuer claim, and every authenticated request then fails with 401 Invalid or expired token, sign-in included. It reads as a credential problem; it is an
algorithm one. So the EC key has to be there.
But once a key set exists, the service rejects every algorithm outside it —
which would break the service_role token against its own admin API. Hence the
second, verify-only HS256 entry, with key_ops keeping the EC key the sole
signer so user tokens stay ES256.
The rest of the credentials
Section titled “The rest of the credentials”The same file carries everything else you would not commit:
secrets: managed: edisyl-database: data: DATABASE_URL: "postgresql://user:pass@host:5432/edisyl" edisyl-object-storage: data: OBJECT_STORAGE_ACCESS_KEY_ID: "..." OBJECT_STORAGE_SECRET_ACCESS_KEY: "..."images: pullSecretAuth: enabled: true username: AWS password: "<output of: aws ecr get-login-password --region us-east-1>"The registry password is a special case: it is an ECR authorization token that expires in 12 hours, so it is a seed rather than a steady state. See Registry access for how the chart keeps it alive afterwards, and why seeding one without the refresher is refused.
If you run the LLM gateway
Section titled “If you run the LLM gateway”Enabling bifrost adds two secrets the chart generates for you on a live
install, so nothing goes in this file unless you want to set them yourself:
| Secret | Keys | What it is |
|---|---|---|
edisyl-bifrost |
BIFROST_ADMIN_USERNAME, BIFROST_ADMIN_PASSWORD, BIFROST_RUNTIME_KEY |
The console’s admin credential, and the inference-scoped virtual key the applications infer with — the settings page presents the same key to prove a provider answers. A runtime key you supply must begin sk-bf-. |
edisyl-bifrost-encryption |
BIFROST_ENCRYPTION_KEY |
What every stored provider credential is encrypted under. Cannot be rotated — back it up. |
Under Argo CD or external-secrets the chart generates nothing, so all four
keys have to come from you or your store. See
Known limitations.
If you administer a gateway you don’t run
Section titled “If you administer a gateway you don’t run”Pointing bifrost.external.url at another deployment’s gateway — see
Configuration
— mounts edisyl-bifrost on the API without running a gateway here. The chart
generates nothing for it: the admin credential and the runtime key are that
gateway’s own, so generated values could never open it. All three keys have to
come from you, and with chart-managed Secrets the render refuses them blank.
edisyl-bifrost-encryption is not needed; it stays with whoever runs the
gateway.
Or keep them in a secret manager instead
Section titled “Or keep them in a secret manager instead”For anything beyond a trial, don’t hold credentials in a file at all. Set
secrets.provider: external-secrets and the chart renders one ExternalSecret
per managed secret instead of a populated Secret, reading each key from your
store at runtime.
secrets: provider: external-secrets externalSecrets: # Name a store you already have. storeName: my-cluster-store storeKind: ClusterSecretStore refreshInterval: 1mThis needs the External Secrets
Operator installed in the cluster. The chart
renders the ExternalSecret resources and nothing more — the store itself is
yours, so any backend the operator supports works: AWS Secrets Manager,
Doppler, HashiCorp Vault, Google Secret Manager, Azure Key Vault.
The chart can also render a Doppler ClusterSecretStore for you rather than
you creating one:
secrets: externalSecrets: createStore: project: your-doppler-projectstoreName then defaults to <namespace>-doppler, and storeKind must stay
ClusterSecretStore — the chart refuses the mismatch rather than rendering a
store nothing reads.
Two things that catch people
Section titled “Two things that catch people”The chart generates nothing in this mode. Under the default
k8s-native provider it fills in the handful of keys it owns when you leave
them blank — ENCRYPTION_KEY, MONO_API_KEY, TOKEN_HMAC_KEY,
PROVIDER_STATUS_WEBHOOK_SECRET, LATTICE_API_KEY, INNGEST_EVENT_KEY,
INNGEST_SIGNING_KEY and cryptoKey — generating each once and reading it
back on upgrade so it cannot rotate. Under external-secrets it writes nothing
to your store and generates none of them. You must put every one of them in
the store yourself, with INNGEST_SIGNING_KEY as even-length lowercase hex.
Miss them and Inngest and encryption break with no warning at install time.
Every key a managed secret lists is a required remote reference, and one
missing entry fails the whole ExternalSecret — taking every other key in that
family down with it, not just the one you forgot. This is why the chart adds
the Cloudflare R2 keys to the store contract only when the provider is r2:
listing them unconditionally would fail the entire Cloudflare secret, images
and browser-rendering tokens included, on every install that does not use R2.
You still have to mint the signing keys
Section titled “You still have to mint the signing keys”A secret manager is where the auth signing material lives, not something that
produces it. Generate it exactly as above, then load it into your store under
the same key names rather than writing auth-keys.local.yaml.
Was this page helpful?
Thanks for the feedback.
