Skip to content

Search these docs from your AI tool

  • Antigravity
    {
      "mcpServers": {
        "edisyl-docs": {
          "serverUrl": "https://docs.edisyl.com/mcp"
        }
      }
    }
  • Claude CodeCLI
    claude mcp add edisyl-docs https://docs.edisyl.com/mcp --transport http --scope user
  • CodexCLI
    codex mcp add --url https://docs.edisyl.com/mcp edisyl-docs
  • Cursor
    {
      "mcpServers": {
        "edisyl-docs": {
          "type": "http",
          "url": "https://docs.edisyl.com/mcp"
        }
      }
    }
  • Gemini CLI
    gemini mcp add --transport http edisyl-docs https://docs.edisyl.com/mcp
  • Goose
    {
      "extensions": {
        "edisyl-docs": {
          "enabled": true,
          "name": "edisyl-docs",
          "type": "streamable_http",
          "uri": "https://docs.edisyl.com/mcp",
          "envs": {},
          "env_keys": [],
          "headers": {},
          "description": "",
          "timeout": 300,
          "bundled": null,
          "available_tools": []
        }
      }
    }
  • JetBrains AI Assistant
    {
      "mcpServers": {
        "edisyl-docs": {
          "type": "http",
          "url": "https://docs.edisyl.com/mcp"
        }
      }
    }

    Paste into Settings → Tools → AI Assistant → Model Context Protocol → Add → As JSON. Direct file writing isn’t supported — JetBrains stores this per-version as XML.

  • Junie (JetBrains)
    {
      "mcpServers": {
        "edisyl-docs": {
          "url": "https://docs.edisyl.com/mcp"
        }
      }
    }
  • OpenCode
    {
      "mcp": {
        "edisyl-docs": {
          "type": "remote",
          "url": "https://docs.edisyl.com/mcp"
        }
      }
    }
  • VS CodeCLI
    code --add-mcp '{"name":"edisyl-docs","type":"http","url":"https://docs.edisyl.com/mcp"}'
  • Windsurf
    {
      "mcpServers": {
        "edisyl-docs": {
          "serverUrl": "https://docs.edisyl.com/mcp"
        }
      }
    }

    Supports both stdio and native HTTP connections.

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:

Terminal window
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.

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.

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.

mint-auth-keys.sh
# 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 -eu
umask 077
b64u() { 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.bin
D=$(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 years
ANON=$(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: $SVC
YAML
rm -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, yaml
ks = 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])
CHECK
echo "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.

Shorter, and it does the same work in one pass.

Terminal window
umask 077
python3 - 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 chars
now = int(time.time()); exp = now + 10 * 365 * 24 * 3600
anon = 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).stdout
text = 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")
PY

Needs python3 and openssl. It writes auth-keys.local.yaml in the shape the chart expects; umask 077 keeps the file readable only by you.

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 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.

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.

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: 1m

This 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-project

storeName then defaults to <namespace>-doppler, and storeKind must stay ClusterSecretStore — the chart refuses the mismatch rather than rendering a store nothing reads.

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.

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.

Report incorrect code

Please provide a detailed description of the incorrect code.