Skip to content

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 provideWhere it comes from
key (starts zk_) β€” sent as x-zedgi-keyCreated in the dashboard (open a service β†’ + New key). Shown once.
credential β€” your own DB username/password/databaseYour database. Zedgi never issues or stores these in plaintext.

host/port are set when you register the service, not in the credential.

json
{
  "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):

HeaderFormatDescription
x-zedgi-keyzk_.. (68 chars)Public key identifier
x-zedgi-tsepoch millisecondsWithin Β±5 min of server clock
x-zedgi-nonce32 hex chars (128-bit)Single-use, prevents replay
x-zedgi-sig64 hex charsHMAC-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)
  1. Server checks x-zedgi-ts is within Β±5 minutes
  2. Server checks x-zedgi-nonce has not been seen before
  3. Server decrypts signing_secret_enc from the database
  4. Server recomputes the HMAC and compares constant-time
  5. 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:

ServiceKeysExample
redispassword?, db?, header?{ "password": "s3cr3t", "db": 2 } (omit entirely if password-less)
postgresuser, password, database, ssl?, header?{ "user": "app", "password": "s3cr3t", "database": "prod", "ssl": true }
mysqluser, 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:

ts
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 override

Selection 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 ​

http
GET /api/services/:serviceId/keys

Returns metadata only (id, label, created_at, last_used_at). The key and signing_secret are never returned again.

Create API key ​

http
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 ​

http
DELETE /api/keys/:keyId

Invalidates the key globally. The KV cache entry is deleted so the key stops working immediately.

Account keypair endpoints ​

SDKs can auto-pull this. Manually:

http
GET /api/account/keys/current
x-zedgi-key: zk_live_...

(Works with just the public identifier β€” no signing secret needed. Public data only.)

json
{
  "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.

http
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:

json
{ "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 ​

http
GET /api/account/keys

Returns all keypairs (active and retired), ordered by version descending.

Rotate keypair ​

http
POST /api/account/keys/rotate

Creates a new active keypair and retires the current one. A notification email is sent.

What you do:

  1. The SDK (when publicKey is omitted) or your code fetches the new public key via GET /api/account/keys/current (using your x-zedgi-key).
  2. Re-supply your credential / credentials to createZedgiClient (it will re-encrypt under the new key) or manually produce a fresh x-zedgi-cred blob.
  3. Deploy the update.

Old credential blobs encrypted under retired key versions will stop working after the grace period.

⚠︎
API keys are separate from the account keypair. Rotating your ECIES keypair does not invalidate your API keys β€” they continue to work. You only need to update the 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.)