API surface

Programmatic access is locked down by default

An API key with a typo is one phishing email away from being a forgery factory. We design the API surface so that even if a key leaks, the blast radius is bounded by what you explicitly allowed it to do, from where, and how often.

Executive summary

Every API key is generated as a 32-byte cryptographic random, SHA-256 hashed before storage, and the raw value is shown to the user exactly once. We physically cannot recover or display a leaked key — only revoke it.

Webhook payloads are HMAC-SHA256 signed with a per-endpoint secret you control. Failed deliveries retry with exponential backoff and auto-disable after 15 consecutive failures, preventing a misconfigured endpoint from becoming an availability incident on the receiving end.

Our commitments

Five rules we won't break

Even at customer request — these are the lines that keep the API surface trustworthy.

01

Raw keys are never stored

We persist only the SHA-256 hash. Even with full database access, an attacker cannot replay a leaked key — they can only see it was used.

02

Minimum-permission tokens

API keys carry their own scopes, separate from user identity. A key for a worker can have permission to seal documents but not to invite users.

03

Webhook payloads are always signed

Every delivery includes an HMAC-SHA256 signature over the raw body, computed with a 32-byte secret unique to your endpoint.

04

Failures lock down, not loud

Repeated webhook delivery failures auto-disable the endpoint. We don't keep retrying into a black hole or spam the destination.

05

Rotation never breaks production

Atomic key rotation revokes the old key and provisions the new one in a single transaction. No window where both are absent.

Implementation — API keys

How API keys are hardened

Every value below is drawn from the source code, not aspiration.

Entropy 32 bytes (256 bits) crypto:strong_rand_bytes/1
Format credo_<8-hex> Prefix-indexed for fast lookup; full key shown to user once
Storage SHA-256 hex Raw key never persisted. unique_constraint on key_hash
Authentication SHA-256 + index lookup We SHA-256 the inbound key and look it up against the stored hash. Pre-image resistance is the boundary.
Permissions Per-key scope 3-tier scope (read < seal < full); independent of user role
Rate limit 10 req/min on expensive operations Per-user limit applied to costly resolvers
Expiration Configurable, optional Rejected with 401 after expires_at
Revocation Soft delete with revoked_at Audit-preserved. Revoked keys cannot be reactivated
Rotation Atomic transaction Revoke old + create new in single Repo.transaction/1; no gap
Usage tracking last_used_at, last_used_ip Updated atomically via touch_changeset on every successful auth
Display Prefix only after creation UI shows "credo_a1b2…" — the full key is never recoverable

Implementation — webhooks

How webhook delivery is hardened

Signing algorithm HMAC-SHA256 RFC 2104
Secret length 32 bytes strong_rand_bytes/1
Secret storage Encrypted at rest redact: true on schema; never logged or echoed
Signature header x-credo-signature Format: sha256=<hex digest>
Signed surface Raw JSON body Not the parsed structure — verify against the bytes you receive
Transport HTTPS only HTTP URLs rejected at endpoint creation
Receive timeout 15 seconds Aborts hung connections
Retry attempts 5 maximum Before final dead-letter classification
Backoff schedule ~1m → 4m → 16m → 1h → 4h Exponential, jittered via Oban
Auto-disable threshold 15 consecutive failures Endpoint flipped to inactive; webhook events stop firing
Event filtering Per-endpoint subscription list Subscribe only to events you handle
Delivery isolation Oban background queue API request returns immediately; delivery is async

Example signature verification (Python)


        import hmac, hashlib

def verify(secret: bytes, raw_body: bytes, header: str) -> bool:
    # header is the value of: x-credo-signature
    if not header.startswith("sha256="):
        return False
    expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header.removeprefix("sha256="))
      

Architecture

One audited API surface. Two transports. Every client.

Web sessions and host-app plugins (Adobe, Chrome) speak GraphQL v1. Native iOS, macOS, and Android clients speak gRPC v1 over HTTP/2. Both ride the same authorization model. Both pass through layered, code-enforced limits — the values below are read directly from the running app.

GraphQL v1 web · plugins
lib/credo_web/graphql
  1. 01
    Unicode sanitizer
    Strips RTL-override and zero-width characters before parse — neutralizes invisible-Unicode and Trojan Source bidi attacks.
  2. 02
    Automatic Persisted Queries
    Clients send a SHA-256 hash; raw query text is never re-shipped.
  3. 03
    Query depth limit ≤ 10
    Hard ceiling enforced before resolution. Anything deeper rejects with a typed error.
  4. 04
    Introspection guard
    Schema introspection disabled in production; configurable via a deploy-level flag.
  5. 05
    Tiered complexity ceiling
    Queries: 500 default / 2000 enterprise. Mutations: 100 / 500. Subscriptions: 50 / 200. Per-field cost is uniformly 1; the resolver budget is bounded per operation.
  6. 06
    Authenticate · Authorize middleware
    Token resolved to a scoped user; the same 37-permission model that drives the dashboard gates every resolver.
  7. 07
    Per-user rate limit
    10 req/min on expensive operations — the limit fires before the resolver fires.
  8. 08
    Timeout · Telemetry · Structured logs
    Every operation has a wall-clock ceiling, a telemetry event, and a structured log line — including the originating IP extracted from the rightmost non-private XFF entry.
gRPC v1 native clients
lib/credo/grpc
  1. 01
    HTTP/2 + TLS termination
    Cloudflare-issued cert on grpc.engineeringid.com; plain HTTP refused at the edge.
  2. 02
    AuthInterceptor — explicit allowlist
    Default for every RPC is "authenticated." Public methods must be enumerated by fully qualified path in @public_rpcs — a privilege-by-default policy, not opt-out.
  3. 03
    Org-scoped authorization
    Scope-filtered queries enforce per-user/org isolation. A native client cannot escalate beyond what the user’s org grants.
  4. 04
    Sensitive-data filter — enforced in tests
    Per project rule: to_proto/1 helpers must never emit signing certs, stamp image bytes, rejection reasons, verifier identity, or credential secrets. Test suite asserts this on every server module.
  5. 05
    Wire-compatibility lock
    .proto files are frozen at v1: no field removal, no renumber, no type change, no enum reorder, no RPC rename. Native clients deployed today will keep working in five years.
  6. 06
    Unary RPCs over HTTP/2
    Unary RPCs over HTTP/2 multiplexing; manifest events transport embedded JSON for forward-compat.
Why two transports? GraphQL gives our web and plugin clients a single flexible query surface. gRPC gives our native apps a strictly-typed, version-locked binary contract. The server is ready today; native Connect-Swift / Connect-Kotlin clients are in progress. Both transports terminate in the same Elixir context modules, so the security model is enforced exactly once.
Below both transports — runtime & network
IP allowlist
Per-org admin policy. Rejects at the plug, before any application code.
Session limit + timeout
Concurrent-session cap and idle-timeout enforced server-side, not by the client.
Trust-aware IP extraction
Rightmost non-private XFF entry. Spoofing the leftmost header doesn’t move our needle.
Single source of truth
GraphQL resolvers and gRPC servers both delegate to the same Credo.* context modules. One bug fix, one audit.

The full picture

What's built, what's being built, and what we chose not to build

Serious engineering means being explicit about the boundaries — including the work we deliberately rejected and why.

Live today

API keys with SHA-256 hashed storage

Live

Raw keys are never persisted; we can verify but not recover.

Per-key scopes and rate limits

Live

3-tier scope (read < seal < full). Gateway limits: 100 req/min/IP on the API; 60 req/min/IP on the public verify endpoint.

HMAC-SHA256 webhook signatures

Live

Per-endpoint 32-byte secret signs the raw JSON body. RFC 2104.

Auto-disable on 15 consecutive failures

Live

Endpoints flagged inactive automatically; admin must re-enable after fixing.

Atomic key rotation

Live

Single Repo.transaction wraps revoke + create. No window of inconsistency.

Building now

Per-key IP CIDR allow-lists

Building now

Restrict an API key to specific source IPs at the gateway. Already supported in the schema; admin UI in progress.

Target: this quarter.

GitHub secret scanning push protection

Building now

Register the credo_ prefix with GitHub so leaked keys are blocked at commit time and revoked automatically on push.

Target: registration submitted; awaiting onboarding.

Webhook destination domain allow-list

Building now

Org-level setting that restricts webhook endpoints to a strict list of hostnames.

Roadmap

Mutual TLS for webhook delivery

Roadmap

Optional client-cert authentication on outbound webhook delivery. Adds transport-level identity binding on top of HMAC.

Driven by Enterprise customer requests; tracking real demand.

Signed receipts on webhook acknowledgement

Roadmap

Recipient signs the response back to us so we can prove the destination received and processed the event.

Per-key request signing (HMAC over the request)

Roadmap

Optional second factor that signs the full request body alongside the bearer token, for extra-paranoid integrations.

Considered & rejected

Blockchain anchoring of API events

Considered & rejected

A chained SHA-256 audit log gives the same tamper-evidence at zero cost per write.

Why we rejected it: blockchain adds latency, unbounded cost, and probabilistic finality without solving any concrete trust problem we have. Cryptographic chaining is the published-standard answer to 'prove this log is unaltered.'

JWT-based API access tokens

Considered & rejected

Statefully-issued opaque keys give us instant revocation; JWTs would require a second blocklist anyway.

Why we rejected it: the only argument for JWTs over opaque tokens is 'no DB lookup,' but the moment you need revocation you're checking a blocklist on every request — at which point opaque tokens with constant-time hash compare are simpler and faster.

OAuth 2.0 device flow for service accounts

Considered & rejected

A long-lived API key with explicit scope is the right primitive for unattended workloads.

Why we rejected it: device flow assumes a human at a phone confirming a code. Server-to-server integrations don't have one. Forcing OAuth where it doesn't fit creates worse security than a well-designed API key.

Storing the last 4 characters of API keys

Considered & rejected

The prefix tells you which key was used; the suffix tells an attacker how close they are.

Why we rejected it: any displayable 'fingerprint' beyond the prefix lets an attacker confirm a partial-leak guess. We show prefix-only, period.

Compliance mappings

Controls this surface satisfies

For your auditor's worksheet — concrete control IDs, not marketing claims.

SOC 2 CC6.1

Logical Access — Authentication

Hashed credential storage, per-key scopes, constant-time auth

SOC 2 CC6.6

Logical Access — Network Segmentation

HTTPS-only webhook delivery; signed payload integrity

SOC 2 CC7.2

System Operations — Anomaly Detection

Per-key usage tracking, automatic disable on repeated failures

ISO 27001 A.9.4.2

Secure log-on procedures

Token-based auth with revocation, expiration, scope

ISO 27001 A.10.1.1

Cryptographic controls

HMAC-SHA256 (RFC 2104) for all webhook signing

ISO 27001 A.12.4.1

Event logging

Every API request logged with key prefix, IP, endpoint, status

For compliance teams

Questions you don't need to call to ask

What happens to API keys when an employee leaves?
Keys are owned by the user who created them. When the user is removed from the organization, an admin can either reassign or revoke each of their keys. Revocation is instant and irreversible.
Can I restrict an API key to specific source IPs?
Schema and middleware support per-key CIDR allow-listing today. The admin UI for editing the allow-list is shipping this quarter; in the meantime, allow-lists can be set via the API. Requests outside the configured range are rejected with HTTP 403 and logged as denied access.
How are leaked secrets detected?
We watch for known credential-leak signals: abnormal request volume per key, IP and user-agent anomalies, and (in progress) GitHub secret scanning push protection on the credo_ prefix. Confirmed leaks trigger automatic revocation and admin notification.
Do you support mTLS for webhook delivery?
Mutual TLS is on the roadmap for Enterprise. Today, our HMAC-SHA256 signature is the authentication boundary; mTLS adds transport-level identity binding for the small set of customers who already have a PKI.
What's logged for each API call?
Timestamp, key prefix (never the full hash), source IP, request method + path, response status, and latency. Logs are retained per your audit retention policy and are part of the hash-chained audit log.

Build with confidence on a hardened surface

Read the API reference, or talk to our security team about your specific control requirements.