Mechanism, not adjectives.
Anyone can write “bank-grade security”, so professionals discount the phrase to zero. What cannot be faked is a named algorithm, a described threat model, a stated limit and an admission of what is missing. This page is that, and it is deliberately longer and duller than a trust badge.
Portfoleo has not launched. Some of what follows describes behaviour that is implemented and covered by tests, and some describes the design that the hosted build is being brought up to before it accepts a paying customer. Where something is not built yet, this page says so in the same sentence rather than in a footnote. If you find a claim here that the shipped software does not support, that is a defect and we want the report.
01Where your data lives
There are three deployment shapes and they have genuinely different privacy properties. Conflating them is the most common way a local-first claim becomes a lie, so they are separated here.
This website — portfoleo.ai
- Static files on Cloudflare Pages
- No cookies, no analytics, no tag manager
- No third-party host in any script, style, font or image
- Fonts vendored into this origin
- Nothing you do here is associated with an account
Hosted app — app.portfoleo.ai
- One PostgreSQL database, every tenant row keyed by organisation
- Large artifacts on a per-tenant directory tree, path-composed by a single guarded accessor
- Encrypted nightly backups, 30-day retention
- Session cookies only, prefixed and host-locked
- United States region at launch
Desktop and self-host
- The same engine against a local SQLite file
- Master key created on first run in your operating system keychain
- No account, no login screen, one local principal
- Outbound calls happen only to providers you configure
- With a local model, the copilot leaves nothing behind
What never leaves your machine or your tenant
- Plaintext API keys, in any deployment
- Your positions, runs and notes, to any other tenant
- Your copilot conversations, into any shared search index
- Anything at all, to an advertising or analytics network — there are none
02Tenant isolation
The product started life as a single-operator tool. Turning that into multi-tenant software is the highest-risk work in the whole build, because the failure mode is silent: tenant A gets a correct-looking answer computed from tenant B’s data and nobody sees an error. The defence is layered on purpose.
- Identity, not a filter
- Every request resolves to exactly one organisation before any handler runs, from an opaque session cookie or a scoped API token. There is no organisation identifier in a URL or a query string, so there is nothing to tamper with.
- No context is an error
- Tenant identity lives in a context variable. Code that asks for it without one gets an exception, never a default and never an empty result. That exception is not caught anywhere; it surfaces as a 500 and raises an alert. A function that returns an empty list when it cannot tell who is asking is how leaks hide.
- Caches are keyed, not annotated
- Every in-process cache is registered as shared, tenant-keyed or provenance-gated. The
analytics caches that used to be keyed by run name alone — where two tenants both
calling a run
baselinewould collide — are keyed by organisation and run. A test creates deliberately colliding names and asserts each tenant gets its own numbers. - A second wall in the database
- PostgreSQL row-level security is applied with FORCE under a non-owner role, so a query issued outside a tenant transaction raises an error rather than quietly returning zero rows. There is an explicit kill switch for it, because a hard dependency you cannot disable at three in the morning is its own kind of risk.
- Retrieval is split
- The copilot’s retrieval corpus is per tenant. Your documents and your question and answer history are indexed in your own store, and the path that used to promote conversation feedback into a shared corpus is severed. A shared index built from user content is an exfiltration channel with a friendly name.
- Websockets are attributed, not merely authenticated
- A streaming connection is resolved to an organisation before it is accepted, is registered under that organisation, and is force-closed when its session is revoked.
The acceptance bar for this work is a test suite, not a review: a marker value written into tenant A must appear in zero responses to tenant B across every API route, identifier-addressed routes must answer 404 rather than 403, and an unclassified new route fails the build.
03Bring-your-own keys, and how they are encrypted
Portfoleo is a software layer over data and models you already pay for. That means you hand us credentials, which makes their handling the most consequential thing on this page.
The mechanism
- Envelope encryption, AES-256-GCM throughout. A root key of 32 bytes is loaded from a file the service account can read and nothing else can: mode 0400, supplied by the init system as a credential. It is never in the database, never in the container image, and never in version control.
- A key-encryption key per organisation, derived with HKDF-SHA256 from the root key, a 16-byte random salt stored on the organisation row, and an info string that includes the organisation identifier. Two tenants therefore have unrelated key material even though there is one root.
- A fresh data-encryption key per secret version. The data key is wrapped under the organisation key; your credential is encrypted under the data key.
- Authenticated additional data binds the ciphertext to its owner. The AAD is the organisation, the secret name and the version. A ciphertext row copied from one tenant to another fails to decrypt with an authentication-tag error — the copy does not silently work.
- A fingerprint, not the value. We store an HMAC-SHA256 fingerprint truncated to 8 bytes plus the last four characters as a display hint. The fingerprint is what lets the interface say “this is the same key you had before” and lets abuse detection notice one leaked key spread across forty free accounts — without decrypting anything.
- Rotation does not touch your data. Rotating the root key rewraps the data keys; the ciphertexts are untouched. It is online, resumable and transactional per organisation.
The handling rules
- The write endpoint returns the name, the hint and the fingerprint. It does not return the plaintext — not even the value you submitted one millisecond earlier.
- Credentials are never written into the process environment. The old behaviour of reading a keychain at import time and injecting into environment variables is deleted for the hosted build.
- In code, a secret is an object whose text representation is redacted and which has exactly one accessor, so every place that can read a plaintext key is greppable in one command.
- The root logger carries a redaction filter that scrubs known vendor key shapes, authorization header values, and the exact values of any secrets in flight. The redaction works by known prefix and exact value rather than by entropy shape, because an entropy-shaped rule also destroys hashes, request identifiers and stack traces — and then you cannot debug the incident the redaction was built for.
- Deleting a key is a soft delete for 30 days, so an accidental deletion is recoverable, and the key is immediately inactive.
- The audit log records that a key was written and its fingerprint. It never records a value.
04What we can see, and what we cannot
This is the section most vendors skip. It is also the only one a security-minded buyer actually reads, so here is the threat model without the marketing.
What an operator can see
- Your email, organisation name, plan and billing status
- Which secret names you have set, their fingerprints and last four characters, and when each was last used
- Your usage counters and audit trail
- Database contents for support and incident response, under audit
- With access to both the root-key file and the database, an operator could decrypt a stored credential
What no operator can do
- Read a key out of a log, an error report, an exception trace or a diagnostics endpoint
- Retrieve a key through any API, including as the account owner
- Decrypt one tenant’s secrets using another tenant’s key material
- Place, route or send an order — the code to do it is absent from the build
- See anything at all in the desktop build, which never talks to us
We are not claiming zero-knowledge encryption and we are not going to. The hosted service has to use your credential to make a request on your behalf, so the plaintext necessarily exists in memory at the moment of use, and the root key necessarily exists on the node. There is no hardware security module and no external key-management service today. What we do claim, precisely: keys are encrypted at rest under per-tenant key material, are bound cryptographically to their tenant, are never in the environment, never in logs, never in an error report and never returned by an endpoint — and if you want a stronger guarantee than that, run the desktop build, where the whole question is yours.
05The local-first claim, stated precisely
“Local-first” is the most abused phrase in this category. Here is exactly what it means for Portfoleo, split by what you actually run.
| You are running | Your prompt and the computed figures go | Who holds the model account |
|---|---|---|
| Desktop or self-host, local model | Nowhere. The request never leaves the machine. | Nobody. There is no account. |
| Desktop or self-host, your model key | Directly from your machine to that model provider. | You. We are not in the path at all. |
| Hosted, your model key | From our node to that model provider, under your key. | You. The provider’s data policy for your account governs. |
| Hosted, our model quota | From our node to the model provider, under our account. | Us. The provider’s data policy for our account governs. |
So be precise about what the hosted tier is. If you use the hosted product with the included copilot quota, your question and the figures the engine computed for it are sent to a third-party model provider. That is not local, we will not describe it as local, and no amount of architecture diagram changes it. What is true is that the architecture supports a fully local path end to end, that the same code runs in both places, and that moving to the private path is a deployment choice you can make rather than a feature you have to ask us to enable.
Two things are true in every deployment, hosted included. First, the model never invents a number: figures are computed by the analytics code and handed to the model, which narrates and cites them. Second, the model is not the system of record for anything on screen.
06Fail closed, as a rule with tests behind it
Every default in this system is chosen so that the absence of configuration produces the safe behaviour rather than the convenient one. A partial list, each one a rule that a test enforces:
- A new environment variable that is unset yields the restrictive behaviour. An unarmed capability is denied, and the denial carries a reason a human can read.
- An unavailable data provider returns an explicit not-available result with a reason. It does not fall back to a different source without changing the provenance stamp on the data.
- Quota checks increment and test in a single database statement, so concurrency cannot overshoot a limit.
- A webhook whose signature does not verify returns an error and its body is not persisted. A replayed event identifier is a no-op by unique index, not by application logic.
- Solo mode refuses to bind to anything but the loopback interface. A forgotten environment variable produces a private box on your own machine, never an open multi-tenant server on the internet.
- The snapshot version cannot be set to a floating alias such as
latest; the process refuses to start rather than serve data whose identity can change under you. - The health endpoint reports liveness and a version. It does not report which keys are configured or which capabilities are armed — that is a reconnaissance gift to an unauthenticated caller.
- Outbound network capabilities are individually armed. One that has not been armed is denied, and the denial names the capability rather than failing silently. If the policy module cannot be loaded at all, everything is denied.
07No order execution, and it is structural
The engine Portfoleo grew out of could talk to brokers. The hosted product cannot, and the difference is not a switch.
- The modules are absent, not disabled. The broker adapters, the order submission path and the messaging bot are excluded from the hosted container image entirely. They are not shipped and then turned off; they are not shipped.
- A test asserts absence at runtime. After the hosted application is imported, the loaded-module table contains no broker module and no execution module at all. If someone reintroduces one, the build goes red before it goes live.
- The capability layer reports it as impossible, not merely denied. The trading capabilities report as unavailable by construction, which is a different and stronger statement than “the flag is off”.
- Paper books are simulated and labelled. Order tickets stage into a simulated book that marks to snapshot data. Every paper surface is labelled as simulated in the interface and in every exported file, so a screenshot or a PDF cannot be mistaken for a real statement.
- Live trading is not a product we sell, at any tier, and no roadmap item on this site adds it.
This is also the honest answer we give a payment provider at underwriting, and it is the same sentence in both places: analytics software, no investment advice, no order execution, no market-data resale.
08Data honesty is a security property
A terminal that quietly shows you a stale number is not a usability problem, it is an integrity failure, and it belongs on this page next to the cryptography.
- Source, as-of time, cache age and delay class travel with the data and are rendered next to it, from first paint. The provenance chip never fades in; if it is not there, the data is not shown.
- A failed fetch yields no data rather than a zero, a null, a carried-forward previous value or an interpolation across a gap. If a fallback source is used, the provenance changes to say so.
- Feed responses are checked against the symbol that was requested. A response for the wrong instrument is refused rather than displayed — this is a real defect that was observed and is now guarded.
- The snapshot-mode badge cannot be dismissed. A test dismisses every dismissible element on the page and asserts the badge is still visible.
- The snapshot manifest lists what it excludes, and that exclusion list is rendered verbatim in the product rather than summarised.
- A snapshot older than its staleness threshold changes the badge wording and raises an operational alert on our side.
09Dependencies, secrets and logs
- Pinned dependencies
- The Python runtime is installed from a fully pinned lock file, so a build is reproducible and a transitive upgrade cannot arrive unannounced. Security advisories against pinned versions are tracked and the pins are moved deliberately.
- No secrets in the repository
- There are no environment files in version control. Platform secrets are supplied by the init system or the secret manager at run time; tenant secrets live only in the encrypted vault described above.
- Logs are redacted at the root
- The redaction filter is installed on the root logger and on the access logger, and the access logger is configured to omit query strings. The primary defence is that a secret object cannot print itself; the filter is the backstop for the case where somebody stringifies one anyway.
- Audit trail
- Login success and failure, key writes and reads, plan changes, session revocations and organisation deletion are recorded with actor, target, address and time. Metadata carries fingerprints; it never carries secret values.
- Backups
- Encrypted nightly database backups with 30-day retention, plus a restore rehearsal into a scratch database. A backup nobody has restored is not a backup, so the restore is part of the acceptance criteria rather than a good intention.
- Sessions
- Opaque, revocable, server-side session records — not self-contained tokens — so revoking one takes effect immediately. Cookies use the host prefix, which forces secure transport and a fixed path and forbids a domain attribute, so a sibling subdomain can neither set nor read them. Idle expiry 14 days, absolute expiry 30 days, never extended. Cross-site request forgery is blocked by a double-submitted token bound to the session record, plus host, origin and fetch-metadata checks.
- Rate limits
- Signup, login by address and login by account, password reset, API, socket connects, corpus ingest, outbound webhooks and outbound email are each bucketed. The per-account login limit exists specifically to close the distributed-attacker hole that a per-address limit leaves open.
10This website, audited in public
You can verify all of the following in your browser’s developer tools while you read this paragraph, which is the point of stating it.
- Third-party requests
- Zero. Every stylesheet, font and image on this site is served from this origin. There is no content delivery network for assets, no font service, no icon service and no analytics.
- Scripts
- This page ships none at all. The supporting pages of this site are static documents with an empty script budget; the interactive controls are built with native HTML elements and CSS.
- Cookies
- None on this site. The product sets exactly two, both strictly necessary: a session cookie and a request-forgery token.
- Content security policy
- Every fetch directive resolves to this origin or to nothing, so a third-party asset cannot be loaded even by mistake. Framing is denied, the document base is pinned and form submissions are restricted to this origin.
- Transport
- HTTPS everywhere, with strict transport security including subdomains, content-type sniffing disabled and a referrer policy that does not leak paths to other origins.
- Fonts
- Two families, subset and vendored into this origin: JetBrains Mono and Archivo, both under the SIL Open Font License, whose full text ships alongside them at /fonts/OFL-Archivo.txt.
11What we do not have yet
Claiming a certification you cannot produce is the fastest way to lose a security-minded reader, so here is the gap list, unhedged.
Not yet
- NO SOC 2 Type I or Type II
- NO ISO 27001
- NO Independent penetration-test report
- NO Bug bounty programme or paid rewards
- NO Hardware security module or managed key service
- NO Single sign-on, SAML or SCIM
- NO European data residency
- NO Published uptime service level agreement
What we have instead
- A stated threat model with its limits written down
- Isolation asserted by a test suite that blocks the build
- Envelope encryption with per-tenant key derivation
- An execution path that is absent rather than disabled
- A deployment you can run yourself and inspect
- A changelog that records removals as well as additions
If your firm needs a completed questionnaire, a subprocessor list or a data-processing agreement to evaluate this, ask and you will get the honest version, including the blanks.
12Reporting a vulnerability
Write to [email protected]. A human reads it. There is no form and no portal.
What helps
- What you found, where, and the impact you believe it has
- Reproduction steps precise enough to follow without you
- The time you tested and the account or address you tested from, so we can find it in the audit trail
- Whether you have shared it anywhere else, and any deadline you intend to hold us to
What we commit to
- An acknowledgement from a person within three business days
- An assessment and a remediation plan within ten business days
- Credit on the changelog if you want it, and silence if you do not
- Coordinated disclosure at 90 days by default, sooner if the fix is out, later only by agreement
Safe harbour
- Good-faith research within this policy will not be met with legal action from us.
- Please test against your own account. Do not access, modify or retain another person’s data; if you encounter it, stop and tell us what you saw.
- Please avoid denial-of-service testing, social engineering of any person, and physical attacks.
- There is no monetary reward today. That is a resourcing fact, not a comment on the value of the report, and it is stated up front so nobody wastes an afternoon expecting one.