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.
What Portward is, in one paragraph
Section titled “What Portward is, in one paragraph”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.
Assets
Section titled “Assets”| Asset | Where 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 kinds | Environment variables named in [setup.sign_in.<kind>]; never in the widget or the store |
| Answer records, reveal/share log, setup/governance log | Gateway’s local SQLite file, hash-chained |
| Definitions, context, links, access rules | Same file (metadata, no row values) |
Trust boundaries
Section titled “Trust boundaries”- AI provider (Anthropic/OpenAI) ↔ gateway: MCP over HTTPS. The provider sees tool calls, results (tokens and non-sensitive columns), and what people type.
- Widget (sandboxed frame on claude.ai / chatgpt.com) ↔ gateway: direct HTTPS
fetchcalls 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. - 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. - Gateway ↔ identity provider: the gateway validates JWT access tokens against the provider’s published keys (JWKS).
- Admin ↔ setup widget: connection settings are typed into the widget and posted straight to the gateway. They never pass through the AI.
- 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 toGET /setup/oauth/callback. These two pages are the gateway’s only GET endpoints besides/health,/readyand/ready/release./readysays 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/releaseis the same without the key and log checks, for ECS and its load balancer. - 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.
Threats and mitigations
Section titled “Threats and mitigations”| # | Threat | Mitigation | Residual risk |
|---|---|---|---|
| T1 | The AI provider learns sensitive values | In-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 link | What people type, and columns not marked sensitive, reach the provider under the customer’s plan terms |
| T2 | AI-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 cap | Covered by regression tests for every finding from four security reviews |
| T3 | Inferring values of “totals & averages only” columns | Bare 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 |
| T4 | Someone other than the person uses a reveal, share or setup link | Links 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 person | A stolen link works for its holder within the TTL (bearer semantics) |
| T5 | Unauthenticated use of the gateway | OAuth 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-auth | Misconfigured provider audiences; mitigated by audience defaulting to the connector URL |
| T6 | A 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 reload | An 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 |
| T14 | Someone 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-store | The 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 |
| T15 | A stored refresh token is stolen | It 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 environment | Anyone 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 |
| T7 | Setup quietly weakens protection | Config-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 log | An admin can deliberately loosen rules they own; it is logged |
| T8 | Tampering with the audit trail | Answer records, the reveal/share log and the setup/governance log are each hash-chained. /health and portward records verify detect edits, deletions and reordering | An attacker with file access can delete the whole file. Back up the file and copy the logs to another system for stronger guarantees |
| T9 | Prompt injection makes the AI misuse tools | The 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 |
| T10 | The widget leaks values into the AI’s context | Real 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 send | We rely on the host not reading the sandboxed frame |
| T13 | Linking a named person to their data in the AI’s context | The 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-limited | If 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 |
| T11 | Denial of service | Request size limits, per-person rate limits, statement timeouts, database work off the event loop, capped link profiling | Not hardened for internet-scale abuse; run behind the customer’s ingress |
| T12 | Identity spoofing in logs | Identity is the IdP’s sub, or email only when email_verified is true | |
| T16 | A 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 id | A config without users (older installs) admits anyone the provider signs in |
Not in scope / known gaps
Section titled “Not in scope / known gaps”- 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.