Security, isolation & trust

How hdls keeps your workspace's data yours — and how you can verify every claim on this page yourself, from your AI assistant.

💬 Just ask

  • "Am I connected securely, and which workspace am I in?"
  • "Create a test record, then show me I never had to pass a tenant id."
  • "What can someone with a member role do versus an admin?"

You don't call these tools yourself — just tell your assistant. The technical reference below is for when you want to verify a specific guarantee.

At a glance

hdls is a portfolio of headless, MCP-native backends. You operate it from Claude, Cursor, Codex, or any MCP client — there's no dashboard to log into and no API keys to paste around. That model changes what "security" looks like for you as a customer, so here's the short version:

  • Your data is pinned to your workspace, in the database itself. Every credential is locked to exactly one workspace (tenant). You never pass a tenant_id — the server stamps it. Even a bug in a tool can't read another workspace's rows, because the isolation is enforced by Postgres at the row level, not by application code.
  • You connect with one-click OAuth, not shared passwords. Authorize once in your browser; your assistant receives a short-lived token scoped to your workspace and the products you enabled. Nothing to copy, nothing to leak.
  • Tokens are pinned and self-healing. Access tokens are signed, short-lived (10 minutes), and audience-pinned so they can't be replayed elsewhere. Refresh tokens rotate on every use, and a stolen-and-reused token automatically kills the whole connection.
  • Webhooks are signed so you can trust them. Every event hdls sends you carries an HMAC-SHA256 signature header — verify it before acting.
  • Bring-your-own-database connections can't be turned against your network. hdls refuses to connect a linked database to internal, loopback, or cloud-metadata addresses, and pins the connection so it can't be rebound mid-flight.
  • Secrets are encrypted at rest. Webhook signing secrets and linked-database connection strings are envelope-encrypted and only decrypted at the moment they're used.

The rest of this page explains each guarantee in plain language, names the role you need where one is required, and shows you how to verify it.


The mental model: one credential, one workspace

A workspace is a single tenant. Products (CRM, Support, Contacts, …) are installed into it. Every credential you hold — whether a browser OAuth grant or a server-to-server API key — is bound to one workspace and carries a role.

There are four roles, lowest to highest:

RoleCan do
readerRead records
memberRead + write records, manage webhooks
adminEverything member can, plus install products and invite teammates
ownerEverything, plus uninstall products

Because the workspace is baked into your credential, you never supply a tenant_id to any tool. If you tried, there's nowhere to put it — the named product tools don't accept one. The server stamps your tenant on every read and write.


Tenant isolation — your data is pinned in the database

What you get: Records you create are visible only inside your workspace. No tool call, from you or anyone else, can read or change another workspace's rows — and vice versa.

How it works (plainly): hdls enforces isolation in Postgres itself using row-level security. Every table tags each row with your workspace and the database is configured so that a query can only ever see rows tagged with the workspace tied to the current credential. This is a database-level guarantee, not a check that application code has to remember to perform. Even the low-level generic tools run under the same rule.

The runtime that serves your tool calls connects to Postgres as a role that cannot bypass row-level security and is not a superuser. So "the app forgot to filter by tenant" is not a failure mode that exists here — the database refuses to return the rows in the first place.

Verify it yourself:

  1. Create a record in your workspace:

    create_account
    { "name": "Acme Robotics", "domain": "acme.example" }
    
  2. Search and confirm it's there — note you never typed a workspace id:

    search_accounts
    { "query": "Acme" }
    
  3. The only way to see another workspace's data is to hold a credential for that workspace. There is no tool argument, header, or trick that crosses the line.


One-click OAuth 2.1 — no shared passwords

What you get: You connect your assistant by approving a consent screen in your browser. No API key to copy into a config file, no password shared between teammates, no long-lived secret sitting in your shell history.

How it works (plainly): hdls is its own authorization server. When your MCP client connects to https://hdls.ai/api/mcp, it walks you through a standard OAuth 2.1 sign-in and consent. You choose which products the connection can reach; that decision is frozen into the grant and cannot be widened later by the client. Your assistant receives:

  • a short-lived access token (valid 10 minutes), and
  • a refresh token it uses quietly in the background to stay connected.

The flow uses PKCE (S256 only — the weaker plain method is rejected), so an intercepted authorization code is useless to an attacker. Authorization codes are single-use and expire in 5 minutes.

Connect:

Concierge (control plane):   https://hdls.ai/api/mcp
Each product:                https://hdls.ai/api/mcp/<slug>   e.g. /api/mcp/crm

Point your MCP client at the URL and approve the browser prompt. That's the whole setup.

Tokens are pinned, short-lived, and self-healing

A few properties make a stolen token far less useful than a stolen password:

  • Audience-pinned. Each access token is signed and stamped with the specific hdls resource it's for. A token minted for hdls won't be accepted anywhere else, and the verifier rejects any token whose audience doesn't match exactly.
  • Short-lived. Access tokens live 10 minutes. Even in a worst case, a leaked access token expires on its own, fast.
  • Rotating refresh, with reuse detection. Every time your client refreshes, the old refresh token is retired and a new one issued. If an already-used refresh token ever shows up again — the classic signature of a stolen token — hdls treats it as theft and revokes the entire connection for that client immediately. The legitimate client simply re-authorizes; the thief is locked out.

Refresh tokens themselves expire after 30 days of inactivity.

Scoped API keys for server-to-server

When there's no human in the loop — a cron job, a backend service — you can mint a scoped API key instead of using OAuth. Keys:

  • are bound to one workspace and carry a role,
  • can be given an expiry and revoked at any time,
  • are shown to you once at creation and only ever stored hashed.

Treat them like passwords: store them in your secret manager, never in source control. For anything interactive, prefer OAuth — there's no secret to manage at all.


Signed webhooks — trust the events you receive

What you get: hdls can POST you a notification whenever records change. Every delivery is signed, so your endpoint can prove it really came from hdls and wasn't tampered with in transit.

How it works (plainly): When you subscribe, hdls returns a high-entropy signing secret once. Every event body is signed with HMAC-SHA256 and the signature travels in the X-Hdls-Signature header (format v1=<hex>). Your endpoint recomputes the HMAC over the raw body with your secret and compares — if it doesn't match, drop the request.

Deliveries are at-least-once, so each event also carries an x-hdls-event-id header. Store the ids you've processed and ignore repeats; that makes your handler idempotent.

Subscribe (needs member or higher):

subscribe_webhook
{
  "target_url": "https://hooks.example.com/hdls",
  "schema_name": "crm",
  "event_types": ["insert", "update"]
}

The response includes a secretstore it now; it is never shown again.

Verify before acting (pseudocode — verify the raw body bytes, not a re-serialized object):

const expected = "v1=" + hmacSha256Hex(yourSecret, rawRequestBody);
if (!constantTimeEqual(expected, request.header("X-Hdls-Signature"))) {
  return 401; // not from hdls, or tampered — ignore
}

Rotate the secret if it ever leaks, or on a schedule (needs member+). The old secret stops verifying on the very next delivery:

rotate_webhook_secret
{ "id": "<subscription-id>" }

Two more assurances worth knowing:

  • Your signing secret is encrypted at rest and only decrypted at the instant a delivery is signed. It is never returned by list_webhooks and never logged.
  • hdls won't deliver to internal or loopback targets — see the next section; the same protection applies to webhook destinations.

Bring-your-own-database — protected against SSRF and DNS rebinding

What you get: If you link your own Postgres database to hdls, that connection can't be abused to reach inside hdls's network or your cloud's metadata service.

How it works (plainly): A connection string is a powerful thing to hand a server, so hdls validates it hard:

  • It must be a real postgres:// / postgresql:// network host. Unix-socket and host-less strings are rejected.
  • The host is resolved and checked against a blocklist of private, loopback, link-local, and cloud-metadata ranges (including 169.254.169.254, 127.0.0.0/8, the RFC1918 ranges, and their IPv6 equivalents). If any resolved address is in a blocked range, hdls refuses.
  • To close the DNS-rebinding gap, hdls resolves and validates the host's IP once, then pins the connection to that exact address — so a hostname can't be re-pointed to an internal host in the moment between the check and the connect. Your real hostname is still used for TLS certificate verification, so security isn't weakened.

This check runs both when you link the database and every time hdls opens a connection to it — defense in depth, so a database that somehow slipped past the first check still can't connect to a forbidden host.

Linking a database is an admin/owner action, and the connection string you provide is envelope-encrypted at rest — stored as ciphertext, decrypted only in memory to open the pool, and never logged or returned.

Most customers never touch this — the default managed database needs no setup and is isolated by row-level security as described above. BYO-DB is for teams who want their records to live in their own Postgres.


Least privilege at the schema level

What you get: A compromised runtime credential can't reshape your data's structure — only the operations its role allows.

How it works (plainly): hdls separates runtime privileges from schema-changing privileges. The role that serves your everyday tool calls can read and write rows but cannot create, alter, or drop tables, and cannot bypass row-level security. Schema changes run as a separate, non-superuser owner role and only through admin-gated, audited operations. Superuser access is used nowhere at runtime — only once, during initial setup.

For you, this means the named product tools (create_account, search_contacts, …) are the surface you work with; structural/DDL operations aren't exposed on product endpoints at all. You can still safely tailor a product without a schema change — for example, adding a custom field whose value rides along in the record:

add_custom_field
{ "entity": "account", "name": "renewal_quarter", "type": "text" }

Promoting a personal custom field to a shared, team-wide one is admin-gated (promote_custom_field).


Per-tenant rate limits & monthly quotas

What you get: No other workspace's traffic can starve yours, and runaway usage is capped predictably by your plan rather than failing in surprising ways.

How it works (plainly): Before every tool call, hdls enforces two limits, scoped to your workspace:

  1. A per-minute request limit (a fixed one-minute window). The default is 120 requests/minute when your plan doesn't specify otherwise; paid plans set their own.
  2. A monthly tool-call budget drawn from the same usage ledger that powers billing — so the number you see and the number you're billed on are one source of truth. Plans with no budget set are unlimited.

When you exceed a limit, the tool returns a clean rate_limited error telling you the limit and window — it doesn't silently drop your call or open a support ticket. Back off and retry.

The counters live in the shared database, not in any single server's memory, so the limit is correct even when your traffic is spread across many serverless instances.


What's your responsibility vs. ours

Concernhdls handlesYou handle
Cross-workspace isolationEnforced in Postgres (RLS); you never pass a tenant id
Connecting your assistantOAuth 2.1 + PKCE, signed short-lived tokensApprove the consent screen; review connected clients
Server-to-server keysHashed at rest, scoped, revocableStore in a secret manager; rotate/revoke; never commit
Webhook authenticityHMAC-SHA256 signature + event-id on every deliveryVerify the signature before acting; dedupe on event id
Webhook & DB secretsEncrypted at rest, decrypted only at useStore the one-time secret somewhere safe
BYO-DB safetySSRF blocklist + DNS-rebind pinning + encryptionProvide a real, externally-reachable Postgres host
Fair usagePer-tenant rate limit + monthly quotaHandle rate_limited with backoff

Admin-only actions & honest limits

  • Installing products and inviting teammates require admin or owner. Uninstalling requires owner.
  • Linking a BYO database requires admin/owner. Promoting a custom field to the team requires admin.
  • Managing webhooks (subscribe_webhook, rotate_webhook_secret, unsubscribe_webhook) requires member or higher.
  • One-time secrets are one-time. API keys and webhook signing secrets are shown exactly once. If you lose one, rotate (webhooks) or mint a new key — there's no way to retrieve the original.
  • Access-token revocation is bounded by expiry. Revoking a connection stops all future refreshes immediately; an already-issued access token remains valid until it expires (at most 10 minutes).
  • Webhook delivery is at-least-once, not exactly-once. Build idempotent handlers using the x-hdls-event-id header.
  • Rate-limit windows are fixed, not rolling. A burst can cross a minute boundary; pace your calls and handle rate_limited rather than assuming a smooth rolling average.

Quick verification checklist

  • Connected via the browser OAuth prompt — no key was pasted anywhere.
  • Created and searched a record without ever supplying a tenant_id.
  • Subscribed a webhook and confirmed my endpoint rejects a request with a bad X-Hdls-Signature.
  • Stored my one-time webhook secret (and any API key) in a secret manager.
  • Confirmed admin-only actions (install/invite/uninstall) behave per my role.
  • My webhook handler is idempotent on x-hdls-event-id.

Questions about a specific guarantee? Ask the concierge at https://hdls.ai/api/mcp (search_docs) — it can point you to the exact behavior for any product you've installed.


Where to go next