# Security and privacy

> How Investi protects your account, tokens and broker connection, what it stores, and what it sends to third parties.

Source: https://investi.live/docs/support/security-privacy

Investi is a read-only research tool: no part of it can place, change or cancel a broker order. This page describes how it protects your account and data, exactly as the code implements it, and lists every outside service it talks to.

## Passwords

- Passwords are hashed with scrypt (N = 16384, r = 8, p = 1) with a random 16-byte salt and a 64-byte derived key. Only the hash is stored.
- Verification uses a constant-time comparison.
- When someone signs in with an email that has no account, Investi still runs a full hash, so response timing does not reveal which emails exist.
- New passwords must be at least 10 characters.
- Emails are stored trimmed and lower-cased, and each email can have only one account.

## Sessions

- Signing in creates a random session token, set in the `investi_session` cookie.
- The cookie is `HttpOnly`, `SameSite=Lax`, scoped to `/`, and `Secure` in production.
- Sessions last 30 days. The database stores only the SHA-256 hash of the session token, with its expiry and your browser's user agent.
- Signing out deletes the session row and clears the cookie.
- After sign-in, Investi redirects only to a path on the same origin, so a crafted link cannot send you to another site.

### Cross-site request protection

API calls authenticated by the session cookie that change state (`POST`, `PUT`, `PATCH`, `DELETE`) must be same-origin. The `Origin` header's host must equal the request host, or, without an `Origin` header, `Sec-Fetch-Site` must be `same-origin`. Anything else is rejected with `403` and code `csrf`. The OAuth consent form applies the same origin check.

## Login rate limits

| Limit | Value |
| --- | --- |
| Per account (email) | 8 attempts per minute |
| Per IP address | 30 attempts per minute |

The IP comes from the hosting platform's client IP header, not the spoofable left-most `X-Forwarded-For` entry. Over the limit, the sign-in page shows a rate-limit error.

> **Warning:** The limiter lives in server memory. On a deployment with several instances, each instance keeps its own counts, and a restart resets them. It stops casual guessing on a personal deployment, not a distributed attack. Put the deployment behind a web application firewall if that matters to you.

## Account creation and the setup lock

- Sign-up is open: anyone who can reach `/setup` can create an account. Account creation is capped at 10 per client IP per hour, using the same in-memory limiter as login (see the warning above).
- Each account is its own private workspace: your portfolio, history and research records are keyed by your user id. Market data caches are shared.
- The first account is recorded in a single-row setup lock inside the same database transaction that creates it, so two simultaneous sign-ups cannot both become the first account.

## Personal access tokens

- Tokens start with `inv_pat_` and carry 32 random bytes.
- Investi stores only the SHA-256 hash, the scopes, a name and when the token was last used. The token itself is shown once, when you create it.
- Tokens do not expire on their own. Revoke them in [Settings](/docs/app/settings), Connections.
- Tokens can be created, listed and revoked only from a signed-in browser session, never with another token.

## Scopes

| Scope | Grants |
| --- | --- |
| `market:read` | Quotes, price history and market session |
| `portfolio:read` | Positions, orders, transactions and round trips |
| `research:read` | Company research, verdicts and reports |
| `workspace:write` | Save notes, watchlists and alerts in Investi (never broker writes) |
| `simulate` | Run hypothetical buy and sell and stress simulations (never submits orders) |

No scope can trade. `workspace:write` changes only data inside Investi. Some actions are refused for every token regardless of scope and work only in the signed-in app: managing tokens, changing strategy rules, changing settings, syncing or disconnecting Schwab, and loading or removing the sample portfolio. See [REST API](/docs/integrations/rest-api#session-only-endpoints).

## OAuth for Claude and other MCP clients

Investi runs a small OAuth 2.1 authorization server for MCP clients such as Claude. See [Claude (MCP)](/docs/integrations/mcp) for setup.

- **PKCE with S256 only.** The consent page refuses a request without an S256 code challenge, and the token endpoint checks the verifier.
- **Public clients with dynamic registration.** Clients register themselves. Redirect URIs must be `https`, or `http` on `localhost` or `127.0.0.1`, with no fragment. At consent, the redirect URI must exactly match one the client registered.
- **You choose the scopes** on a consent screen while signed in. The scopes the client asked for are pre-checked; if it asked for none, `market:read portfolio:read research:read` are pre-checked.
- **Single-use codes.** An authorization code is marked used in the same statement that reads it, so it cannot be redeemed twice. The code must also match the client and the redirect URI.
- **Rotating refresh tokens.** Each refresh revokes the refresh token it used and issues a new pair. A reused refresh token is rejected. A refresh can narrow scopes but never widen them.
- **Hashed at rest.** Codes, access tokens and refresh tokens are stored only as SHA-256 hashes.

| Credential | Lifetime |
| --- | --- |
| Authorization code | 10 minutes |
| Access token (`inv_at_`) | 1 hour |
| Refresh token (`inv_rt_`) | 30 days |

Revoking a connected application in Settings revokes its refresh tokens and all of its access tokens.

## Schwab connection

- Broker access uses only Schwab's official OAuth flow. Investi never sees your Schwab password.
- The OAuth `state` value is bound to an `HttpOnly` cookie scoped to `/api/schwab` that expires after 10 minutes.
- Schwab's access and refresh tokens are encrypted at rest with AES-256-GCM, using a random 12-byte IV per encryption and the 32-byte key from `INVESTI_ENCRYPTION_KEY`. Without that key, the Schwab connection does not work.
- Investi requests accounts, positions, balances, orders and transactions with read-only calls. It has no code path that places, changes or cancels orders.
- Schwab requires you to re-authorize every 7 days. Disconnecting deletes the stored tokens.

See [Connect Schwab](/docs/getting-started/connect-schwab).

## Security headers

Every response carries:

| Header | Value |
| --- | --- |
| `X-Content-Type-Options` | `nosniff` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
| `X-Frame-Options` | `DENY` |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` |

The `X-Powered-By` header is turned off. API responses are sent with `Cache-Control: private, no-store`.

## Audit log

Investi records security-relevant events in an `audit_events` table, with the user, the actor (web, MCP, OAuth or system) and a small detail object. Writing an audit event never blocks the request it describes.

| Area | Events |
| --- | --- |
| Accounts and sign-in | Owner or user created, login succeeded, login failed (with the attempted email) |
| Tokens and OAuth | Token created, token revoked, OAuth grant approved |
| MCP | Every tool call, with the client, success or failure, and duration |
| Schwab | Connected, synced (counts only), disconnected |
| Data | Transactions imported, positions imported, import deleted, sample loaded or removed, order recorded |
| Research | Note created, alert created, verdict override, policy saved |

The audit log is stored in your database. There is no screen in the app for browsing it yet.

## Where your data is stored

Everything lives in one Postgres database that you control:

- With `DATABASE_URL` set, Investi uses that Postgres server. This is required on serverless hosts such as Vercel.
- Without it, Investi uses PGlite, an embedded Postgres stored on disk at `apps/web/.data/pglite` (or `INVESTI_DATA_DIR`).

The database holds your account, sessions, imported and synced brokerage records (accounts, transactions, orders, balances, positions), research evaluations and reports, notes, watchlists, alerts, saved screens, settings, OAuth clients and tokens, the encrypted Schwab tokens, the audit log, and cached market data. Secrets are stored hashed (passwords, sessions, tokens) or encrypted (Schwab tokens).

There is no in-app way to delete an account yet. Self-hosters can remove data directly in the database.

## What Investi sends to outside services

| Service | When | What is sent |
| --- | --- | --- |
| Nasdaq public endpoints | Quotes, history, calendars, analyst consensus | Ticker symbols |
| SEC EDGAR | Filings and financial statements | Company identifiers, with the contact string from `SEC_USER_AGENT` |
| Schwab Trader API | Only if you connect Schwab | Read-only requests with your Schwab OAuth token |
| Anthropic | Only if `ANTHROPIC_API_KEY` is set and you ask for an explanation | One company report (see below) |
| GitHub API | When someone opens `/downloads` | A request for the latest public release; no user data |

### Plain-language explanations with Anthropic

When `ANTHROPIC_API_KEY` is set, the research page offers a plain-language explanation of a verdict. It runs only when you request it, through `POST /api/v1/securities/{ticker}/explain`.

- Investi sends the company report for that ticker as JSON, minus its provenance list and disclaimer. The report includes your own trading history in that company: realized P/L, round-trip counts, buy and sell counts, open quantity and average price, your last buy and sell, and your open orders for it. It does not include your other holdings, account numbers, email or credentials.
- The model is told to use only facts in the report, to treat the report's text as data rather than instructions, and never to recommend buying or selling.
- Claude explains the verdict; it never computes it. Verdicts and numbers come from Investi's rules engine.
- The explanation is cached with that report revision, so asking again for the same revision does not send the report a second time.

Without the key, nothing is sent to Anthropic and the endpoint returns `503 ai_unavailable`.

> **Note:** The Claude connector is separate. When you connect Claude through MCP, Claude calls Investi's tools with the scopes you granted, and the results go to Claude under your Claude account. See [Claude (MCP)](/docs/integrations/mcp).

## Related

- [REST API](/docs/integrations/rest-api)
- [Chrome extension](/docs/integrations/chrome-extension), including its own security model
- [Desktop app](/docs/integrations/desktop-app)
- [FAQ](/docs/support/faq)
