Authentication & Security β
Zedgi uses a defence-in-depth approach: two-value API keys for identity, HMAC-SHA256 request signing for integrity, and ECIES credential encryption for confidentiality.
What you provide β
To call Zedgi you only need two things:
| You provide | Where it comes from |
|---|---|
key (starts zk_) β sent as x-zedgi-key | Created in the dashboard (open a service β + New key). Shown once. |
credential β your own DB username/password/database | Your database. Zedgi never issues or stores these in plaintext. |
host/port are set when you register the service, not in the credential.
{
"id": "a1b2c3d4e5f6...",
"key": "zk_live_1a2b3c4d5e6f7890abcdef1234567890abcdef12",
"label": "production"
}The key is shown once and is irrecoverable β save it. That is the only value you handle.
The signing secret is automatic β
Every request is also HMAC-signed, but you do not manage the signing secret. Each API key has its own signing secret (generated at creation, stored AES-256-GCM encrypted). The SDKs pull it once via GET /api/account/signing-secret (authenticated by your x-zedgi-key) and cache it in memory β so signing is transparent. You only pass a signingSecret explicitly if you want to manage it yourself (e.g. signing requests by hand without an SDK).
Request signing β
Every RPC request must carry four headers (the SDKs add these for you):
| Header | Format | Description |
|---|---|---|
x-zedgi-key | zk_.. (68 chars) | Public key identifier |
x-zedgi-ts | epoch milliseconds | Within Β±5 min of server clock |
x-zedgi-nonce | 32 hex chars (128-bit) | Single-use, prevents replay |
x-zedgi-sig | 64 hex chars | HMAC-SHA256 of canonical message |
Canonical message β
message = `${ts}:${nonce}:${sha256hex(body)}`
body = JSON.stringify(rpcPayload) // raw JSON, not an object
sig = HMAC-SHA256(message, signing_secret)The nonce must be a fresh 128-bit random value for every request. The server rejects replayed nonces (stored in KV until the timestamp window expires).
Verification flow β
x-zedgi-sig = HMAC-SHA256("1749600000000:a1b2...:3c4d...", signing_secret)- Server checks
x-zedgi-tsis within Β±5 minutes - Server checks
x-zedgi-noncehas not been seen before - Server decrypts
signing_secret_encfrom the database - Server recomputes the HMAC and compares constant-time
- Nonce is stored in KV (global replay protection across PoPs)
Credential encryption (ECIES) β
Your credential carries only the secret connection fields β host/port live on the registered service, not here. The credential never travels in plaintext: it is turned into the x-zedgi-cred "link" (encrypted client-side). What goes in credential depends on the service type:
| Service | Keys | Example |
|---|---|---|
redis | password?, db?, header? | { "password": "s3cr3t", "db": 2 } (omit entirely if password-less) |
postgres | user, password, database, ssl?, header? | { "user": "app", "password": "s3cr3t", "database": "prod", "ssl": true } |
mysql | user, password, database, ssl?, header? | { "user": "app", "password": "s3cr3t", "database": "prod" } |
Set ssl: true for managed Postgres/MySQL that requires TLS. header is optional for every service. If the credential object includes a header key, that header object is not public-key encrypted; it is included in the signed RPC body and forwarded to the proxy as plaintext metadata.
Credential profiles β
Each /rpc request carries exactly one encrypted credential blob in x-zedgi-cred. The SDK can choose that one blob from named credential profiles:
const zedgi = createZedgiClient({
url: 'https://dev123.zedgi.app',
key: process.env.ZEDGI_KEY!,
credentials: {
redis: {
default: { password: process.env.REDIS_PASSWORD!, db: 0 },
cache: {
password: process.env.REDIS_PASSWORD!,
db: 1,
header: { 'x-firewall-token': process.env.REDIS_FIREWALL_TOKEN! },
},
},
postgres: {
default: { user: 'app', password: process.env.PG_PASSWORD!, database: 'app' },
reporting: { user: 'reporter', password: process.env.PG_REPORTING_PASSWORD!, database: 'reports' },
},
},
});
zedgi.redis(); // credentials.redis.default
zedgi.redis('cache'); // credentials.redis.cache
zedgi.postgres('reporting');
zedgi.redis({ password: process.env.REDIS_PASSWORD!, db: 2 }); // ad-hoc overrideSelection order is: ad-hoc credential object, named profile, service default, legacy credential, then no credential. Missing named profiles fail client-side before a request is sent. Custom hooks use the credential selected for their Redis/Postgres/MySQL service.
header is optional and special. It is removed before ECIES encryption, added to the signed RPC body as credentialHeader, and forwarded as plaintext metadata for proxy/firewall integrations. Do not put secrets in header unless that receiving proxy or firewall must read them as plaintext metadata.
Account keypair β
Every account has an X25519 keypair, generated on first sign-in:
- Public key β safe to share; used for client-side encryption
- Private key β wrapped with the Master KEK (AES-256-GCM); stored in D1
Two-hop encryption β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β Client β β Gateway β β Proxy β
β β β (Worker) β β (Node) β
β credentials β β β β β
β β β β β β β
β βΌ β β β β β
β ECIES encryptβββββββΆβ ECIES decryptβ β β
β (account pub)β β β β β
β β β ECIES encryptβββββββΆβ ECIES decryptβ
β β β (RPC node pk)β β β
β β β β β connect + β
β β β β β execute β
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββClient β Gateway hop:
Key derivation: X25519(ephemeral_priv, account_pub) β HKDF(secret, "zedgi-cred-client-gateway-v1") β AES-256-GCM key
Blob format (base64url-encoded binary):
0x01 (1 byte β version)
accountId (16 bytes β raw hex)
keyVersion (2 bytes β uint16 BE)
ephemeralPub (32 bytes β X25519 raw)
iv (12 bytes β AES-GCM nonce)
ciphertext (variable)
tag (16 bytes β appended by GCM)Gateway β RPC node hop:
Same ECIES scheme, different HKDF info: "zedgi-cred-gateway-rpc-v1"
The double-encrypted blob is cached in L1 (memory, rotated every 5 min) and L2 (KV, TTL 1 hour). No plaintext credential ever enters the cache.
Decrypt cache β
Cache key: SHA-256(encryptedCredBlob) β ciphertext in, ciphertext out. On miss: full ECDH decrypt β re-encrypt β populate cache. On hit: zero crypto, zero network.
API key management endpoints β
List API keys β
GET /api/services/:serviceId/keysReturns metadata only (id, label, created_at, last_used_at). The key and signing_secret are never returned again.
Create API key β
POST /api/services/:serviceId/keys
Content-Type: application/json
{ "label": "production" }Returns the key β save it immediately (it is never shown again). The signing secret is managed for you and is not returned here (see below).
Delete API key β
DELETE /api/keys/:keyIdInvalidates the key globally. The KV cache entry is deleted so the key stops working immediately.
Account keypair endpoints β
Get current public key (for the credential link) β
SDKs can auto-pull this. Manually:
GET /api/account/keys/current
x-zedgi-key: zk_live_...(Works with just the public identifier β no signing secret needed. Public data only.)
{
"key_version": 1,
"public_key": "lRk7...base64url...",
"created_at": 1749600000000
}Use public_key (and the key_version embedded in the blob) when you or the SDK encrypt credentials into the x-zedgi-cred "link".
Get the signing secret (for auto-sign) β
SDKs auto-pull this and cache it; you rarely call it directly.
GET /api/account/signing-secret
x-zedgi-key: zk_live_...Returns the signing secret belonging to that API key so the SDK can sign on your behalf:
{ "signing_secret": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }Because the caller already holds the API key (which can make calls), returning its own signing secret grants nothing extra β it just removes the secret from your config. Pass signingSecret to the SDK explicitly only if you'd rather manage it yourself.
List all keypairs β
GET /api/account/keysReturns all keypairs (active and retired), ordered by version descending.
Rotate keypair β
POST /api/account/keys/rotateCreates a new active keypair and retires the current one. A notification email is sent.
What you do:
- The SDK (when
publicKeyis omitted) or your code fetches the new public key viaGET /api/account/keys/current(using yourx-zedgi-key). - Re-supply your
credential/credentialstocreateZedgiClient(it will re-encrypt under the new key) or manually produce a freshx-zedgi-credblob. - Deploy the update.
Old credential blobs encrypted under retired key versions will stop working after the grace period.
x-zedgi-cred link (re-encrypt credentials with the new public key). Rate limiting β
Rate limits are enforced per-user, based on balance:
- Free tier: 1,000 req/day
- Paid tier: proportional to balance
When exceeded, the server returns 429 RATE_LIMIT_EXCEEDED.
Caller-IP allowlisting β
When creating a service, you can optionally specify a whitelisted IP. This is a source-IP allowlist: only API requests that originate from that IP are accepted. The caller's IP is read from Cloudflare's edge (cf-connecting-ip) and checked at the gateway, before the request reaches the proxy β so a stolen API key used from any other IP is rejected with 403 IP_NOT_WHITELISTED.
Leave it unset to allow requests from any IP. (The Playground tests from your browser and is not subject to the allowlist.)