Skip to content

Portward threat model

Version 2026-10-21, updated the same day to cover setup for every source kind and sign-in. It covers the customer-run gateway (one Docker image) and its two MCP connectors, plus the widgets they render inside Claude and ChatGPT. It is written for a design partner’s security review.

A gateway the customer runs next to their databases. People ask questions in Claude or ChatGPT. The AI writes SQL, and the gateway checks the SQL and runs it read-only. The gateway replaces the values in columns the admin marks sensitive with tokens before anything reaches the AI provider. Real values go only to the asking person’s browser, through a widget, and only on their click. Every answer, reveal, share and admin change is written to a tamper-evident log on the customer’s side. Portward (the company) receives no customer data.

AssetWhere it lives
Customer data (rows)The customer’s databases; read on demand, never stored by the gateway
Sensitive values (e.g. emails, names)Same; leave the database only as tokens (AI path) or to the person’s browser (widget path)
Gateway key (PORTWARD_KEY)Customer secret store / env var; derives separate subkeys for tokens, signed links and secrets
Connection settings added in setup (passwords, access keys, Snowflake and BigQuery service-account keys, Azure SAS tokens, Cloud Storage HMAC keys, the Microsoft 365 token cache and the Google Drive refresh token)Gateway’s local SQLite file: the whole form of each connection encrypted as one AES-GCM secret under a key derived from the gateway key; never sent back to the widget
Sign-ins in progress (OAuth state hash, PKCE verifier, tokens until the connection is saved)Same file, encrypted the same way; expire after 10 minutes (30 once signed in)
The operator’s OAuth app (client id, secret) for sign-in kindsEnvironment variables named in [setup.sign_in.<kind>]; never in the widget or the store
Answer records, reveal/share log, setup/governance logGateway’s local SQLite file, hash-chained
Definitions, context, links, access rulesSame file (metadata, no row values)
  1. AI provider (Anthropic/OpenAI) ↔ gateway: MCP over HTTPS. The provider sees tool calls, results (tokens and non-sensitive columns), and what people type.
  2. Widget (sandboxed frame on claude.ai / chatgpt.com) ↔ gateway: direct HTTPS fetch calls to the gateway’s widget endpoints. These carry no cookies or bearer token; each call carries a signed, short-lived link minted for the signed-in person.
  3. Gateway ↔ databases: a read-only role, TLS (verify-full) for remote hosts. The derived token key is sent per transaction, because tokens are computed in the database.
  4. Gateway ↔ identity provider: the gateway validates JWT access tokens against the provider’s published keys (JWKS).
  5. Admin ↔ setup widget: connection settings are typed into the widget and posted straight to the gateway. They never pass through the AI.
  6. Admin’s browser ↔ gateway ↔ sign-in provider (Microsoft, Google): for sign-in kinds the widget opens GET /setup/oauth/start (a signed link) in a new window; the provider redirects back to GET /setup/oauth/callback. These two pages are the gateway’s only GET endpoints besides /health, /ready and /ready/release. /ready says whether this version started correctly and which checks failed. It gives the reasons only to callers on the gateway’s own machine, and runs the checks at most once every 5 seconds, however many callers ask. /ready/release is the same without the key and log checks, for ECS and its load balancer.
  7. Gateway ↔ object stores, BigQuery, Microsoft Graph and the Google Drive API: the connection’s own credentials (or the signed-in account) over HTTPS; files are read in place or in memory, never stored.
#ThreatMitigationResidual risk
T1The AI provider learns sensitive valuesIn-database HMAC tokenization before results leave the DB; real values only via the widget, fetched on click with single-use links. A test confirmed that after a page reload, the provider holds only an expired linkWhat people type, and columns not marked sensitive, reach the provider under the customer’s plan terms
T2AI-written SQL escapes the rules (writes, other tables, file reads)Parser-based allowlist (sqlglot): one SELECT, known tables only, no table functions, LATERAL, VALUES or recursive CTEs; a CTE is referred to with exactly the spelling and quotes it was defined with (engines fold unquoted names differently). Real tables are replaced by tokenized subqueries. Read-only transaction, statement timeout, row capCovered by regression tests for every finding from four security reviews
T3Inferring values of “totals & averages only” columnsBare SUM/AVG/COUNT only, in one plain SELECT. Every group needs ≥ k rows (counted by gateway row ids, each row at most once, so a join that repeats a row leaves the group out) and ≥ k distinct values. No USING/NATURAL joins, field access or HAVING of the AI’s own (an engine may evaluate it on the groups left out, and an error would tell about their values)Differencing (subtracting two large totals) is not prevented. Use “Token” for values that must never be inferred. When the gateway leaves out a group, that shows all its values are equal
T4Someone other than the person uses a reveal, share or setup linkLinks are HMAC-signed over the purpose, record, expiry and the signed-in subject. Reveal links are single-use with a 2-minute TTL; click links last 10 minutes; setup links last 10 minutes and are renewed on use. Rate limits apply per personA stolen link works for its holder within the TTL (bearer semantics)
T5Unauthenticated use of the gatewayOAuth resource server: 401 plus RFC 9728 discovery. Tokens must be issued for the specific connector URL (audience), so a data token can’t open the admin connector. Admin requires a role or a listed subject. Running without sign-in needs an explicit --no-authMisconfigured provider audiences; mitigated by audience defaulting to the connector URL
T6A malicious or careless admin connects a hostile database (to collect the token key)Setup may connect only to hosts the operator allowlists (setup_hosts), for every database kind (Snowflake: <account>.snowflakecomputing.com); a custom S3 or Azure Blob endpoint must be listed too, since it receives the credentials (Cloud Storage, BigQuery and Google Drive talk only to Google, so they have no endpoint field). The operator can also limit which kinds setup may connect ([setup] kinds). The form has fixed fields only (no driver options, hostaddr, passfile or CA paths). A server CA can only be pasted as certificates, which are parsed and rewritten, so no file of the gateway’s can be named. Hosts on shared cloud domains (*.amazonaws.com, Databricks) must be listed exactly. TLS for remote hosts, certificate and host name always verified (Postgres verify-full, MySQL VERIFY_IDENTITY, SQL Server through a FreeTDS section that names the CA file and checks the host name, Oracle TCPS with the server DN matched; Snowflake, Redshift verify-full and Databricks always), against the pasted CA, else Amazon’s RDS bundle for RDS hosts, else the public roots. Each engine’s own startup checks run before saving: no superuser/sysadmin/db_owner/administrator or write-capable login, in-database tokens match; tables the login can write are shown but can’t be exposed; BigQuery credentials holding any write or admin permission on a discovered table are refused outright. Azure SAS tokens must be container SAS (sr=c) with read and list only, name their permissions (no stored access policy, no account SAS) and be unexpired; the check runs again at every reload, so an expired token disconnects. Removing a host from setup_hosts disconnects connections to it at the next reloadAn allowlisted database is trusted with the token key. A wildcard on a shared SaaS domain (e.g. *.snowflakecomputing.com) admits any account on it, including an attacker’s: list the exact account hosts. SQL Server and MySQL write detection reads grants (HAS_PERMS_BY_NAME, SHOW GRANTS with roles); unusual grant paths (such as nested roles) may be missed, so a read-only login remains the customer’s control
T14Someone else completes or hijacks a sign-in (Microsoft 365, Google Drive)The start link is HMAC-signed over the sign-in id, kind, expiry and the admin it was issued to, single use (also enforced in the store, so across processes) and valid 5 minutes. The OAuth state is 256 random bits, stored only as a SHA-256 hash, accepted once and only within 10 minutes; PKCE (S256) binds the code to the verifier the gateway keeps encrypted. The tokens are usable only by the admin who started the sign-in, for 30 minutes, to save one connection. Callback pages are static text (escaped) with default-src 'none', no-referrer and no-storeThe start link is a GET (browsers open it), so it can appear in proxy logs; it is single use and short-lived. Whoever completes the provider’s sign-in in that window decides which account is connected. The admin sees the files listed before anything is exposed
T15A stored refresh token is stolenIt is kept only in the encrypted setup store (never on disk in plain text, never in the widget or tool results); a rotated one replaces it there. Scope is read-only on the signed-in account (Files.Read; Google: drive.readonly, and a Google sign-in that grants any other scope is refused). Google refresh tokens also need the operator’s client secret, which stays in the environmentAnyone with both the SQLite file and PORTWARD_KEY (plus, for Google, the client secret) can use it until the account’s sessions are revoked in Microsoft Entra or the Google account’s third-party access
T7Setup quietly weakens protectionConfig-file tokenization is a floor setup can’t lower. When columns are added after the rules were saved, the table stays hidden until an admin reviews them. Every change is in the hash-chained setup logAn admin can deliberately loosen rules they own; it is logged
T8Tampering with the audit trailAnswer records, the reveal/share log and the setup/governance log are each hash-chained. /health and portward records verify detect edits, deletions and reorderingAn attacker with file access can delete the whole file. Back up the file and copy the logs to another system for stronger guarantees
T9Prompt injection makes the AI misuse toolsThe AI can only read what the rules allow. It can’t reveal values (reveals need the person’s click), can’t save a “user-confirmed” link (links saved from chat are marked ai_recorded and rank lowest), and can’t use admin tools (separate connector, admin role)Injected text can still steer the AI’s analysis or wording
T10The widget leaks values into the AI’s contextReal values are never placed in tool results. They are fetched by the frame directly. “Send real values to Claude” (or ChatGPT) is an explicit two-step action, limited to columns marked shareable, and logged. In Claude it goes into the person’s message box for them to sendWe rely on the host not reading the sandboxed frame
T13Linking a named person to their data in the AI’s contextThe AI has no way to turn a typed name into a token. To find someone, the person uses Find in the results widget: the typed text goes from the frame to the gateway, and the matching rows stay in the frame. The AI is told to answer for everyone and point to Find. Each match is logged by token and rate-limitedIf the person types the name into the chat, the AI sees the name but still can’t match it to a row. Find shows whether a value exists in results the person may already see
T11Denial of serviceRequest size limits, per-person rate limits, statement timeouts, database work off the event loop, capped link profilingNot hardened for internet-scale abuse; run behind the customer’s ingress
T12Identity spoofing in logsIdentity is the IdP’s sub, or email only when email_verified is true
T16A stranger signs up at the customer’s identity provider (open self sign-up) and uses the data connector[auth] users lists who may: sign-in ids, verified emails, or a verified email domain (@acme.com); admins always may. portward init writes users = [] (admins only) and portward doctor warns when the list is missing. Refusals are logged with the sign-in idA config without users (older installs) admits anyone the provider signs in
  • Customer-side controls: database hardening, backups, network policy, IdP configuration.
  • Statistical disclosure control beyond T3. There is no differential privacy.
  • Multi-tenant hosting. Each customer runs their own instance.
  • No formal penetration test yet. Four internal security reviews ran on 2026-10-20 and 2026-10-21. Ask security@portward.ai for their summaries.