Security and privacy
How Investi protects your account, tokens and broker connection, what it stores, and what it sends to third parties.
On this page
- Passwords
- Sessions
- Cross-site request protection
- Login rate limits
- Account creation and the setup lock
- Personal access tokens
- Scopes
- OAuth for Claude and other MCP clients
- Schwab connection
- Security headers
- Audit log
- Where your data is stored
- What Investi sends to outside services
- Plain-language explanations with Anthropic
- Related
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_sessioncookie. - The cookie is
HttpOnly,SameSite=Lax, scoped to/, andSecurein 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
/setupcan 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, 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.
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) 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, orhttponlocalhostor127.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:readare 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
statevalue is bound to anHttpOnlycookie scoped to/api/schwabthat 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.
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_URLset, 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(orINVESTI_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).
Related#
- REST API
- Chrome extension, including its own security model
- Desktop app
- FAQ