> ## Documentation Index
> Fetch the complete documentation index at: https://docs.portalhq.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Security model

> How the Portal Web SDK keeps key material and credentials away from your page, and what to check before going to production.

The `@portal-hq/web` package on your page is a thin client. When you initialize `Portal`, it loads a hidden iframe from Portal (`web.portalhq.io`, or your [custom subdomain](./configure-a-custom-subdomain)). Key material and, depending on how you authenticate, Portal credentials live inside that iframe, on a different origin from your application. Your page sends requests to the iframe and receives results such as addresses, signatures, and transaction hashes.

## Where things live

| Item | Where it lives | Readable by your page |
| - | - | - |
| Client signing share | LocalStorage of the iframe origin, keyed per Portal client | No |
| Client Session Token with `authUrl` or [Client Auth](./client-auth) | Inside the iframe | No |
| Client API Key or token passed as `apiKey` | Your page's JavaScript | Yes |
| Backup encryption | A Web Worker inside the iframe | No, except for the Custom backup method, which returns the key to you |
| Firebase backup key | Portal's Trustless Backup Service (see [Firebase Auth Backup](/resources/backup-options/firebase-byo-auth#security-model)) | No |
| Passkey backup key | Portal's Trustless Backup Service | No with the default popup flow. Yes with a [custom passkey domain](./back-up-a-wallet#custom-domain-passkeys), where your page receives the key from `generateBackupShare()` and passes it to `authenticatePasskeyAndWriteKey()` |
| Encrypted client backup share (`cipherText`) | Portal, with [Portal-Managed Backups](./back-up-a-wallet#portal-managed-backups) | Returned to your page as an encrypted value |

Signing is 2-of-2 MPC. The user's signing share and Portal's signing share must both take part, so neither Portal nor anything holding only your page's data can sign alone.

## How your page and the iframe communicate

* All messages use `window.postMessage` with an explicit target origin in both directions.
* The iframe is loaded with your page's origin and only accepts messages from that origin and from its direct parent window.
* The iframe only handles a fixed set of message types and ignores anything else.
* The SDK only accepts replies that come from the Portal iframe's own origin and window.
* Iframe assets are published per SDK version, so each `@portal-hq/web` release loads the iframe built for it.

## What origin isolation covers

Because the signing share and the session stay on the iframe origin, a cross-site scripting (XSS) bug on your page cannot read them.

> [!WARNING]
>
> Injected script on your page can still call SDK methods while the user's session is active, for example to request a signature. Protect your application like any page that can move funds: set a strict Content Security Policy, load no untrusted scripts, and keep server-side checks on the transactions your users send.

## Production checklist

1. **Keep Portal credentials out of your page.** Use [Client Auth](./client-auth) or an [`authUrl`](./web-authentication-methods#authenticating-with-an-authurl). Passing a Client API Key or Client Session Token as `apiKey` exposes it to every script on your page, so reserve it for local development.
2. **Run the SDK on a custom subdomain.** A [custom subdomain](./configure-a-custom-subdomain) such as `portal.yourdomain.com` keeps the iframe first-party, which Safari and other browsers with third-party cookie restrictions require.
3. **Set a Content Security Policy on your application.** Allow the Portal iframe host (your custom subdomain or `web.portalhq.io`) in `frame-src` and `connect-src`. If you use passkey backups without the popup, also allow `backup.web.portalhq.io` in `connect-src`. The default passkey flow opens a popup on the Portal origin, so do not block it.
4. **Leave logging off in production.** `logLevel` defaults to `'none'`. Debug output includes request details meant for local troubleshooting. See [Logging](./logging).
5. **Pin and update the SDK.** Pin an exact `@portal-hq/web` version and upgrade deliberately.
6. **Approve signatures in your UI.** Keep `autoApprove` set to `false` (the default) and handle `portal_signingRequested` to show users what they are signing. See [Custom signature hooks](./add-custom-signature-hooks). This runs in your page, so treat it as a user safeguard rather than an access control.
7. **Gate eject behind fresh authentication.** [`portal.eject()`](./eject-a-wallet) rebuilds the full private key and returns it to your page. Only offer it on an explicit user request after the user re-authenticates.
8. **Prefer Firebase or Passkey backups over Password on web.** With Password backups your page collects the password and passes it to the SDK. With Firebase backups and the default Passkey popup flow, the encryption key goes from the iframe to Portal's Trustless Backup Service and never reaches your page. With a custom passkey domain, your page handles the key during backup, so keep that code path free of logging and third-party scripts.
9. **Only clear the wallet on purpose.** `clearLocalWallet()` removes the signing share from the browser, after which the user needs to recover. For a normal sign-out, end your own session and leave the share in place.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.