Reference

Security

How Dock handles credentials, sessions, and access control, and what you as a user can do when something goes wrong.

Principals

Three ways to authenticate:

  • Human: magic link → session cookie (dock-session, HttpOnly, SameSite=Lax, 30-day expiry).
  • Agent: Bearer token dk_<48 hex>. No expiry by default; revoke any time.
  • OAuth client: for MCP connectors. Dynamic Client Registration + PKCE S256 (mandatory, per RFC 9700, the plain method is rejected). Access tokens 7 days, refresh tokens 30 days (sliding window: each refresh resets the clock, so an active session never re-authenticates while idle ones expire after 30 days).

Credentials at rest

Every Bearer credential Dock issues is stored as a SHA-256 hash, never plaintext. The plaintext is shown to your client exactly once at creation and never persisted anywhere we control.

Coverage: API keys (dk_*), OAuth access tokens (dock_at_*), OAuth refresh tokens (dock_rt_*), and every magic-link / invite / consent-handshake token.

API keys

When you create a key, Dock:

  1. Generates dk_<48 hex> (192 bits of entropy).
  2. Computes SHA-256 of that string.
  3. Stores only the hash + a 10-char prefix (for UI display).
  4. Returns the plaintext to your request once.

From then on, the plaintext exists only in whatever system you stored it in. agent config, environment variable, password manager. On every request, the server hashes the incoming Bearer token and looks up the row by hash. Constant-time via Postgres unique-index lookup.

Implications:

  • If our database leaked, the attacker would get SHA-256 hashes of 48-char random strings. computationally impossible to brute-force.
  • If you lose your plaintext key, we cannot recover it. Mint a new one and revoke the old.
  • The 10-char prefix in the Settings UI (dk_c914f1c6…) is designed to identify a key without revealing it.

OAuth tokens

Same architecture as API keys. The /oauth/token endpoint returns the plaintext access + refresh tokens to the client exactly once; the database stores SHA-256 hashes (access_token_hash, refresh_token_hash), unique-indexed. Verify on every request hashes the incoming Bearer and looks up the row by hash, single-query, sub-millisecond.

Hashing rolled out in two phases. Phase 1 (live) added the hash columns alongside the legacy plaintext ones with a hash-first read path so the migration shipped without invalidating any live sessions. Phase 2 drops the plaintext columns and the fallback path on or after 2026-06-09, once every pre-Phase-1 token has aged out (refresh TTL is 30 days). After Phase 2, a database leak exposes hashes only.

Authentication defenses

Magic links are single-use, 15-minute TTL, and email-bound. The verify call carries the email the user is signing in as; if it doesn't match the email the token was issued for, verification fails 401. A leaked link cannot be redeemed by anyone other than the recipient even within the 15-minute window.

PKCE S256 required

OAuth 2.1 / RFC 9700 compliance. /oauth/authorize rejects requests without code_challenge. Only S256 is accepted, the legacy plain method is rejected. Every MCP connector that talks to Dock has to generate a verifier, hash it, and prove possession at the token-exchange step.

Session safety

Shared-device takeover defense

When a verify call lands on a browser carrying a different user's dock-session cookie, the prior session row is deleted server-side before the new cookie is attached. Any tab still holding the old cookie value gets a clean 401 on its next request, instead of phantom-prior-user access via long-lived connections (SSE streams, streaming responses) that captured the cookie at connect time.

Same-user re-verify (e.g. clicking a magic link on a phone where you're already signed in) is the no-op it should be. Only cross-user verifies trigger the kill.

Session rotation on org role change

When an admin demotes you, removes you, or you remove yourself from an org, every active web session for that user is invalidated server-side. Forces a fresh sign-in on the next request, which picks up the new role / removed access cleanly. Closes the gap where a long-lived SSE stream kept firing under the user's old role until the cookie expired naturally.

Workspace-level role changes deliberately do not rotate sessions, the user may be active in dozens of other workspaces, and the per-request authorization check picks up the new role within milliseconds anyway. Org-level changes are the security boundary.

Network safety

Every URL Dock dispatches to from your data, webhooks, embed lookups, content fetchers, OG previews, passes through a two-layer check before we send a byte:

  1. Format check at save time: blocks raw IPs in private ranges (10/8, 172.16/12, 192.168/16, 169.254/16), loopback, reserved TLDs (.local, .internal, .lan, .home.arpa).
  2. DNS-resolution check at dispatch time: re-resolves the host immediately before fetch and rejects if it resolves to a private IP. Defeats DNS-rebinding (where an attacker registers a public-looking domain that points at a private address). The string-level check passes; the resolution check sees the truth and refuses.

Production only allows https://destinations. Cloud metadata (169.254.169.254 and aliases) is blocked by both layers.

Content safety

User-supplied HTML, SVG, and remixed markdown all flow through DOMPurify with explicit ALLOWED_TAGS + ALLOWED_ATTR lists. URL schemes are restricted to http(s), mailto, and tel. No javascript:, no data:text/html, no on* event handlers survive the sanitizer.

Doc bodies, SVG fenced blocks, and the /remix flow share the same DOMPurify pipeline (real HTML parser, explicit allowlist), replacing the regex-based stripping that previously sat in the remix path. Comment bodies are plain-text-only (no HTML, no markdown rendering). Embeds expand only for hosts on a static safelist (YouTube, Vimeo, Loom, Figma, CodePen, GitHub Gist).

Access control

Two independent axes.

Role

Per-member, per-workspace. Controls what a principal can do:

  • Owner: full control including deletion
  • Editor: read + write rows/docs, manage columns, invite members
  • Commenter: read everything + comment on doc ranges, cells, rows, and the workspace; cannot edit content
  • Viewer: read-only

Visibility

Per-workspace. Controls who can read. Writes always require membership regardless.

  • private: members only (default)
  • org: everyone in the owning org
  • unlisted: anyone with the URL
  • public: anyone, indexable

A user's agent auto-inherits the user's workspace access. If you're an editor on content-pipeline, an agent you mint becomes an editor there on first use. Means one key from the dashboard works on every workspace that key's owner controls.

Signing out

In Settings → Profile → Security:

  • Sign out: invalidates the current browser session.
  • Sign out everywhere: invalidates every session this account owns across all browsers and devices. Redirects to /login. API keys are not revoked. your agents keep working.

Use "Sign out everywhere" if you suspect a cookie leaked, lost a laptop, or worked on a shared machine.

API equivalentbash
curl -X DELETE https://trydock.ai/api/me/sessions \
  -H "Cookie: dock-session=..."

→ { "revokedSessions": 3 }

Revoking an agent

Settings → API keys → Revoke. The next request from that key returns 401. Revocation is immediate. There is no grace window.

API equivalentbash
curl -X DELETE https://trydock.ai/api/keys/:id \
  -H "Cookie: dock-session=..."

Webhook signing

Every outbound webhook delivery is signed with HMAC-SHA256 using the workspace's webhook secret. The header is X-Dock-Signature: t=<unix-ts>,v1=<hex>. Reject any delivery where |now - t| > 5min to defeat replay. After a secret rotation, deliveries carry a second signature v2=<hex> with the previous secret for 24h, so receivers have a graceful window to swap their stored secret. Full recipe in the webhooks reference.

Webhook secrets are currently stored in plaintext (outbound signing needs the plaintext to compute the HMAC). Encryption at rest is on the roadmap.

Rate limits

Sliding-window per IP / per principal:

  • Magic-link send: 5 / hour per email, 20 / hour per IP
  • API writes: 300 / minute per key or session
  • OAuth token exchange: 30 / minute per IP
  • Invites: 20 / hour per workspace

Hitting a limit returns 429 with a Retry-After header.

The rate limiter runs on Upstash Redis in production, a sliding window keyed atomically across every Vercel instance, so spreading requests across instances or waiting for cold starts does not bypass the limits. If the Redis backend is unavailable at deploy time, every request fails closed with a 5xx rather than silently degrading to per-instance in-memory state.

What we don't log into workspace events

The workspace event log records who did what. It does not record the content of API key secrets, webhook secrets, or session tokens. Principal IDs + actions + payload diffs only.

If your key leaks

  1. Go to Settings → API keys and revoke the key immediately.
  2. Mint a new key, paste it into your agent's config.
  3. Check the activity log in affected workspaces for any writes that look wrong; attribution will show the revoked key's agent name. Undo via row history if needed.
  4. If the leak was in git history, rotate via BFG or git-filter-repo . Note that GitHub secret scanning now catches dk_ prefixes automatically.

Threat model

Threat
Mitigation
API key committed to git
Hashing at rest doesn't prevent this. you revoke. `dk_` prefix is scanner-friendly.
Session cookie leaked
15-min magic link expiry + HttpOnly + Sign out everywhere.
DB leak
API keys + OAuth access + OAuth refresh tokens all hashed (SHA-256, unique-indexed). Session tokens not yet hashed (roadmap).
Webhook replay
HMAC + 5-min timestamp window already rejects replays.
Webhook secret rotation drops in-flight events
24h dual-signature grace window: deliveries carry both v1 (new) and v2 (previous) signatures so receivers can swap without dropping events.
Social-engineered invite
Invites email-bound; wrong recipient can't accept.
Magic-link redemption by an attacker who intercepted the email
Verify call carries the expected email; mismatch with the token's identifier returns 401 even if the link itself is fresh.
Brute force on API
Rate limits + exponential backoff per origin. Distributed Upstash Redis backend so cold-start hopping doesn't bypass.
Shared-device session takeover
Cross-user verify deletes the prior session row server-side; any stale cookie gets a clean 401 on next request.
Stale role on a long-lived connection after demote / removal
Org-level role change + removal invalidate the user's sessions; SSE streams reconnect with fresh authorization.
SSRF via DNS rebinding
Two-layer check: format-validate at save time, DNS-resolution-validate at dispatch time. Public-looking host that resolves to a private IP is rejected at send.
XSS via embedded HTML in markdown / remix / comments
DOMPurify with explicit ALLOWED_TAGS + ALLOWED_ATTR. URL schemes restricted to http(s) / mailto / tel. Comments are plain-text-only.
Existence enumeration (does slug X exist in org Y?)
Non-member slug lookups return 404 rather than 403.

Reporting a vulnerability

Email security@trydock.ai with a description, reproduction steps, and (if possible) a proof of concept. We triage within one business day and acknowledge within 24h. We don’t run a paid bounty yet but we credit responsible disclosures publicly on request.

Please don’t: run automated scanners against production beyond a reasonable rate, target other customers’ data, or disclose before we’ve had a chance to fix.

Incident response

We run a simple three-phase playbook when something goes wrong:

  1. Contain: rotate affected credentials (session tokens, API keys, webhook secrets, vendor API keys via each provider’s dashboard), revoke compromised sessions, disable the affected endpoint if needed.
  2. Investigate: timeline from Vercel logs + Postgres audit data + GitHub commit history; preserve logs; identify the scope of data touched.
  3. Notify + remediate: email affected users within 72h of confirmation (GDPR Art. 33/34 standard), deploy the fix, publish a post-mortem.

On-call rotation is the founder while Dock is in beta. Paging runs through Vercel alerts + security@trydock.ai.

Supply chain

Every version of @trydock/cli and @trydock/mcp on npm is published with npm provenance. Releases only flow through a public GitHub Actions workflow on tag push; the npm tarball is cryptographically signed via Sigstore and tied to the exact commit + build log that produced it. On npmjs.com you’ll see a green “Published with provenance” badge and can click through to inspect the source commit. No local npm publishis possible; if a Dock-branded package ever lacks the badge, it’s not ours.

Backup & disaster recovery

  • Primary data (Postgres): hosted on Neon with continuous point-in-time recovery. Free/Pro keep 7 days of recovery window; Scale keeps 14. We verify restore procedures quarterly against a scratch branch.
  • Attachments (Vercel Blob): multi-region storage managed by Vercel. Individual object deletes are reversible within Vercel’s 14-day window.
  • Secrets: Vercel env vars + a founder-only 1Password vault with an offline sealed “break-glass” copy of credentials to rotate everything from zero.
  • Target objectives: RPO 5 minutes (Neon PITR granularity), RTO 4 hours (restore branch, flip DNS, warm up).

Roadmap

  • OAuth Phase 2: drop the legacy plaintext access_token + refresh_token columns, leaving SHA-256 hashes only. Soak-gated for 2026-06-09 (30-day window after Phase 1 prod deploy, so every pre-Phase-1 token has aged out cleanly).
  • Hash Session.sessionToken at rest. The plaintext is in the cookie either way, so this reduces blast radius of a DB leak rather than changing the cookie itself.
  • Encryption-at-rest for webhook secrets (AES-GCM with a server-held master key). Outbound HMAC needs the plaintext to compute, so the wrapper has to live in-process.
  • Per-key IP allowlists + per-key rate-limit overrides
  • Audit log export (ZIP of your event history)
  • Device / IP metadata on sessions for the "active sessions" list
  • SSO (SAML / OIDC) for enterprise orgs

Related: Sharing & roles · Webhooks · API reference

Frequently asked questions

How does Dock encrypt my data?
AES-256 at rest (Postgres + Vercel Blob). TLS 1.3 in transit, HSTS-preloaded. Bearer credentials (API keys, OAuth access tokens, OAuth refresh tokens) are SHA-256-hashed at rest with unique-indexed lookup; the plaintext is shown to your client exactly once at creation and never persisted. SHA-256 is appropriate here because the input has 192+ bits of entropy from a CSPRNG; key-stretching algorithms like argon2id buy nothing on those inputs but cost real auth latency on every request. Stripe handles all payment data on its PCI-compliant infrastructure.
How does Dock store my API keys?
SHA-256-hashed at rest, unique-indexed for O(1) verify lookup. The plaintext `dk_…` value is shown ONCE at creation and never persisted; to recover, mint a new key and revoke the old. The 10-char prefix shown in the dashboard (`dk_c914f1c6…`) identifies a key without exposing the secret.
How does Dock prevent shared-device session takeover?
When a verify lands on a browser carrying a different user's session cookie, the prior session row is deleted server-side before the new cookie is attached. Any tab still holding the old cookie gets a clean 401 on its next request. Same-user re-verify is a no-op; only cross-user verifies trigger the kill.
What happens to active sessions when an admin demotes me?
When an admin changes your org-level role or removes you from an org, every active web session for your account is invalidated server-side. Your next request forces a fresh sign-in, which picks up the new role / removed access cleanly. This closes the gap where a long-lived SSE stream kept firing under your old role until the cookie expired naturally. Workspace-level role changes deliberately do not rotate sessions (per-request authorization picks up the new role within milliseconds anyway).
How does Dock authenticate me?
Magic-link sign-in via Resend (no passwords on the user side). API auth via Bearer (`dk_` keys) or OAuth 2.1 + DCR. Session cookies are HttpOnly + Secure + SameSite=Lax with a 30-day rolling TTL.
Does Dock support SSO (SAML, OIDC)?
OIDC + magic-link today (the magic-link flow IS the OIDC flow under the hood). SAML on the roadmap for Scale tier. Email `security@trydock.ai` for SAML timing if it's a hard requirement.
Does Dock support 2FA?
Magic-link auth is effectively 2FA (you control the email account). For hardware-key 2FA on top, the OAuth flow can route through a 2FA-enabled identity provider. Native TOTP/WebAuthn on the roadmap.
How do I report a Dock security vulnerability?
Email `security@trydock.ai`. PGP key at `https://trydock.ai/.well-known/security.txt`. We respond within 24 hours and follow coordinated disclosure (no public posting until we've had time to patch + notify affected users).
Does Dock have a bug bounty program?
Informal today: meaningful security reports get a thank-you + a Dock credit. Formal HackerOne / Bugcrowd program planned post-SOC 2 attestation. Email `security@trydock.ai` if you've found something; we'll respond regardless.
How does Dock prevent SSRF + cloud-metadata attacks?
Webhook URLs are validated at create-time AND re-validated by DNS at delivery-time: loopback, RFC1918 private ranges, link-local, cloud-metadata addresses (169.254.169.254, etc.) are rejected. Re-validation defends against DNS rebinding.
How does Dock prevent agent prompt-injection from compromising data?
Server-side validation on every write (Zod schemas on every endpoint). Per-key role gating. Dangerous-ops two-call confirmation pattern for irreversible actions. Principal attribution stamps every change so post-incident audit can identify the agent.
Where can I find Dock's security policy + practices?
This page is the public security overview. Detailed risk + controls report (pre-SOC 2): email `security@trydock.ai`. Public security.txt at `/.well-known/security.txt`. Pen-test results shared under NDA on request.
Updated