# OpenSigner | Non-Custodial Wallet Key Management Open-source, non-custodial, self-hostable private key management. # Introduction ## Overview One of the most notable challenges in security is ensuring that a user—and *only* that user—has **continued** access to a secret, regardless of device loss, app reinstallation, or other life events. Traditional approaches often burden the user with managing backups of their secret keys. These keys are long, complex, and difficult to store securely. This complexity often leads users to choose less secure alternatives rather than navigating the secure platform, especially when migrating to a new device. Various solutions have attempted to address this: * **Seed phrases**: Easier than random characters but still difficult to remember and secure. * **Passkeys**: Eliminate the need for memory but can be tricky to transfer between devices and raise backup concerns. * **Password-based encryption**: Vulnerable to brute-force attacks if the password is weak. * **HSMs (Hardware Security Modules)**: Used by companies like Apple, [WhatsApp](https://engineering.fb.com/2021/09/10/security/whatsapp-e2ee-backups/), and Signal. They protect PINs from brute-force attacks but rely on the hardware's security and can be expensive and inflexible. ## Introducing OpenSigner OpenSigner is designed to solve these problems by enabling: * **Users** to: * Securely store cryptographic keys by splitting them into shares stored in separate locations. * Sign data using an ephemeral reconstruction of their private key, used only for a specific operation. * **Organizations and Developers** to: * Manage their users' cryptographic keys. * Abstract the key recovery process via `automatic recovery` or empower users with `password recovery` or `passkey recovery`. ### Architecture ![Component overview](/diagrams/components.svg) OpenSigner consists of three main components: 1. **iFrame**: Performs user operations, handles key splitting, and reconstructs keys. 2. **Key Share Storage**: Keys are split into three shares, stored in different locations: * **User device**: Stored within the iFrame on the user's device. * **Hot storage**: External storage for frequently accessed shares. * **Cold storage**: External storage for shares accessed only when a local or hot share is missing. 3. **Authentication Service**: Verifies user access to shares by issuing access tokens to the iFrame and exposing validation endpoints to the storages. ## How it works ### Splitting OpenSigner uses [Shamir's Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing) to split private keys into three shares. This ensures the key is never stored in a single location, reducing the risk of compromise. ![Splitting overview](/diagrams/sss_splitting.svg) ### Reconstruction The original key can be reconstructed using any two of the three shares. The iFrame component reconstructs the private key only when needed and discards it immediately after use. ## Next steps * Run OpenSigner locally with the [Setup](/introduction/setup) and [Getting Started](/introduction/getting-started) guides. * See how keys are created, recovered, and used in [Create a key](/actions/signup), [Recover a key](/actions/login), and [Sign an operation](/actions/operation). * Review the [security overview](/security/overview) and [deployment scenarios](/security/deployment-scenarios). For the cryptographic background, see Adi Shamir's 1979 paper [*How to Share a Secret*](https://dl.acm.org/doi/10.1145/359168.359176). # Users The OpenSigner wallet key management components share one `user` concept, except for cold storage which expands on it. One user can have none, one, or many keys for a chain. One user can have keys for multiple chains. Keys are not shared across chains. ## Projects When using automatic key recovery, cold storage users rely on projects. Projects manage the entropy for the users' recovery shares. More specifically, projects have access to half of the entropy, while the cold storage has the other half. Projects are containers for users' keys and their recovery share entropy. Projects are given unique API keys (`X-API-Key`), used by clients to identify them. Read more about cold storage authentication in the [Cold Storage](/components/shield) documentation. ## Providers Providers are different ways to authenticate a user in a project. A project may identify its users via the [Openfort](https://openfort.io) auth system or by a custom provider. The same project can enable both authentication methods. In any case, a project can have at most one custom provider, and at most one openfort provider. It also needs at least one valid authentication provider registered and set up. Whenever a request is performed, Shield knows which authentication provider it should use based on the contents in the `X-Auth-Provider` header. This header accepts two values: `openfort` and `custom`. Shield identifies to which project users belong via the `X-API-Key` header, which maps them to their project. Users themselves are mapped using personal keys/tokens. How these keys/tokens look depends on what kind of provider they're using to authenticate. ### Openfort provider The Openfort provider relies on the user's Openfort `publishable_key` to properly identify and authenticate its users. ### Custom providers A custom provider is an external source in charge of authenticating users. Custom providers rely on externally signed `JWT` tokens to **identify and authenticate users**. This means that all keychains and keys created by them are tied to their particular user ID. When a project defines a custom provider, Shield uses the JWT tokens issued by it to identify and authenticate users. Custom providers consist of the following fields: * `jwk_url` A URL pointing to a publicly exposed JWK keyset (usually `.well-known/jwks.json`) * `pem_cert` A PEM file containing the **public** key from the key pair used to sign JWT tokens * `key_type` The type of the key pair used to sign and validate tokens. Supported types are `RSA`, `ECDSA`, and `Ed25519` Both `jwk_url` and `pem_cert` can be specified. At least one is required. Custom providers authenticate users with externally signed [JWT](https://datatracker.ietf.org/doc/html/rfc7519) tokens, validated against a [JWK](https://datatracker.ietf.org/doc/html/rfc7517) keyset. ## Related * Authentication is handled by the [Cold Storage (Shield)](/components/shield) and the [Authentication component](/components/auth). * See [recovery methods](/security/recovery-methods) for how automatic recovery uses project entropy. * Review the overall [security model](/security/overview). # Setup The project Makefile builds and runs all components through docker-compose. Building the images takes time, particularly the Better Auth component used for authentication service database migrations: [`@better-auth/cli`](https://www.npmjs.com/package/@better-auth/cli). First, clone the project: ```shell git clone https://github.com/openfort-xyz/opensigner.git ``` To build the containers, run: ```shell make build # or `make clean build` to remove old images and volumes ``` To run them, use: ```shell make run ``` The components are configured through environment variables. The `docker-compose.yml` file at the repository root lists every variable with its default value. Service-specific defaults are in files such as `auth_service/.env.example`. ## Required Environment Variables These variables have **no defaults**. Docker Compose refuses to start without them, which is deliberate: a credential that falls back to a default is a credential every reader of this repository already knows. Copy `.env.example` to `.env` and fill in each one. | Variable | Used by | Description | |---|---|---| | `JWT_SECRET` | Auth service | Application secret for the auth service. Signs session cookies and encrypts the JWKS signing key at rest. Minimum 32 characters; generate with `openssl rand -base64 48`. The service refuses to start if this is unset or left at a library default. | | `SHARE_ENCRYPTION_KEY` | Hot storage | AES-256 key for encrypting shares at rest. Exactly 64 hex characters (32 bytes). Generate with `openssl rand -hex 32`. | | `POSTGRES_PASSWORD` | PostgreSQL | Superuser password. | | `AUTH_SERVICE_DB_PASS` | Auth service | Postgres password the auth service connects with — usually the same value as `POSTGRES_PASSWORD`. | | `HOT_STORAGE_DB_PASS` | Hot storage | Postgres password hot storage connects with — usually the same value as `POSTGRES_PASSWORD`. | | `COLD_STORAGE_DB_PASS` | Cold storage | Postgres password cold storage connects with — usually the same value as `POSTGRES_PASSWORD`. | :::warning[Set the database passwords before the first start] PostgreSQL fixes its passwords when the data volume is first initialised. Changing these values later does not update an existing volume: you would need `ALTER USER` against the running database, or a fresh volume. Decide them before the first `make run`. ::: ### Rotating `JWT_SECRET` `JWT_SECRET` does more than sign tokens: the auth service uses it to **encrypt the JWKS private signing key stored in the `jwks` table**. Changing the secret without accounting for that key leaves the deployment in a broken or unsafe state, so rotation is a two-step operation. :::warning[Changing `JWT_SECRET` requires purging the `jwks` table] The stored signing key is encrypted with the previous secret. After changing `JWT_SECRET`: ```sql DELETE FROM jwks; ``` The auth service regenerates a signing key under the new secret on the next request. Skip this and `GET /api/auth/token` fails with `Failed to decrypt private key`, because the key on disk cannot be read with the new secret. Then invalidate issued credentials, since tokens signed by the old key are no longer verifiable and sessions predate the rotation: ```sql DELETE FROM session; ``` ::: **Treat the old signing key as exposed.** Anything that could read the previous secret — a configuration file, an environment dump, a database backup, a snapshot, or a log — could also decrypt the signing key it protected. A key is only as private as every copy of the secret that encrypted it, so rotate on any suspicion that a secret was disclosed, and do not reuse a `jwks` row across secrets. Because `hot_storage` releases a wallet key share to any holder of a validly signed JWT, a signing key that can be recovered is equivalent to being able to authenticate as any user. ## Optional Environment Variables These variables have sensible defaults for local development but should be configured for production: | Variable | Default | Description | |---|---|---| | `ALLOWED_ORIGINS` | `http://localhost:7050,http://localhost:7051` | Comma-separated list of allowed CORS origins. Used by both the auth service and hot storage. | | `BETTER_AUTH_BASE_URL` | `http://localhost:7052` | Public base URL of the auth service. | | `POSTGRES_USER` | `postgres` | PostgreSQL superuser name. | | `TRUST_PROXY` | `false` | Set to `true` only when the auth service sits behind a proxy that sets `x-forwarded-for`. Rate limiting is keyed on the client address, so trusting that header from an untrusted source lets a caller send a fresh value per request and sidestep the limit entirely. | | `GOOGLE_JWT_AUDIENCE` | unset | OAuth client ID accepted for `x-auth-provider: google`. While unset, hot storage rejects Google tokens rather than accepting any Google-signed token regardless of the client it was issued to. | | `AUTH_JWT_ISSUER` / `AUTH_JWT_AUDIENCE` | value of `BETTER_AUTH_BASE_URL` | Expected `iss` and `aud` claims that hot storage enforces on incoming JWTs. These are the auth service's **externally visible** base URL, which is why they cannot be derived from `AUTH_SERVER_URL` — that is the internal address used to fetch the JWKS. | | `ALLOW_INSECURE_AUTH_SERVER` | `false` | Permits fetching the JWKS over plaintext HTTP. Hot storage otherwise requires `https` for `AUTH_SERVER_URL`, because anyone able to rewrite that channel can substitute the key set and mint tokens for any user. Local development only. | Each service also accepts database connection variables (`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_SSLMODE`) with defaults suitable for the Docker Compose setup. See `docker-compose.yml` for the full list. :::warning[`make run` disables database TLS] `make run` applies `docker-compose.dev.yml`, which overrides `DB_SSLMODE` to `disable`. The stock PostgreSQL image is built without TLS, so the `require` default cannot connect. **A local run therefore talks to the database in plaintext.** Production must not apply that overlay, and needs a database with TLS enabled to honour `DB_SSLMODE=require`. ::: Once you have everything running, head over to the [Getting Started](/introduction/getting-started) guide. OpenSigner runs as a set of [Docker Compose](https://docs.docker.com/compose/) services. Generate the required `SHARE_ENCRYPTION_KEY` with [OpenSSL](https://www.openssl.org/) (`openssl rand -hex 32`). ## Related * [What is OpenSigner?](/introduction/about) — the architecture and trust model. * [Deployment scenarios](/security/deployment-scenarios) — self-hosted vs managed setups. * [Migrate existing keys](/introduction/import-share-from-openfort) from an Openfort project. # Getting started After starting the components (for example, with `make run`), you can begin using the OpenSigner service. This guide assumes you have already completed the [Setup](/introduction/setup) steps and have all components running locally. ## Open the iFrame The [iFrame component](/components/iframe) is the entry point for end users. It runs on port `7050` by default. Open it in your browser to generate keys, reconstruct them for signing, and run recovery flows. The iFrame is the only place a full private key is ever assembled, and it discards the key immediately after each operation. ## Explore the APIs Each backend component exposes an HTTP API: * [Authentication service API](/apis/auth_service) — issues and validates access tokens. * [Hot storage API](/apis/hot_storage) — stores frequently accessed key shares. * [Cold storage API](/apis/cold_storage) — stores recovery shares. The fastest way to try the endpoints is the [Postman collection](/apis/postman), which is published with [Postman](https://www.postman.com/) and mirrors the live API definitions. ## Try the key flows Once the iFrame is open, walk through the core flows: * [Create a key](/actions/signup) — split a new private key into shares. * [Recover a key](/actions/login) — reconstruct a key on a new device. * [Sign an operation](/actions/operation) — sign using an ephemeral key. For a refresher on how the pieces fit together, see [What is OpenSigner?](/introduction/about). The key splitting is based on [Shamir's Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing). # Import shares from Openfort If you have an active project on Openfort and want to self-host OpenSigner, you need to migrate the existing key shares. OpenSigner requires both the hot share and the cold share to reconstruct a user's private key. :::warning Only shares with custody type **User** can be exported from Openfort. Shares with custody type **Project** cannot leave cold storage. ::: ## Prerequisites * A running OpenSigner instance (see [Setup](/introduction/setup)) * A user registered in your OpenSigner authentication service * An Openfort API secret key with the **Private key shares** scope To update a secret key's scope, go to **API keys** in the Openfort dashboard, click the **...** menu on the key, select **See key scopes**, and enable **Private key shares**. ## Steps ::::steps ## Export the hot share from Openfort Call the Openfort export endpoint with your scoped secret key: ```shell curl -X POST \ 'https://api.openfort.io/v2/accounts/{{ACCOUNT_ID}}/export-share' \ -H 'Authorization: Bearer {{SECRET_KEY}}' ``` The response contains the account data and the hot share: ```json { "id": "acc_9d304037-bcb0-4334-9b7e-d0afc3703bba", "wallet": "pla_569a738d-4825-4706-852f-e17b363e0f20", "accountType": "Smart Account", "address": "0x9dEbC3dE5797005347b965Eb4F6d506162a9574F", "ownerAddress": "0xf69f884717c1738a6e5c429e60c733deccb214e1", "chainType": "EVM", "chainId": 80002, "custody": "User", "share": "ed62a9ce840f3fc1bf3179c3e1b73c13ae50...", "signerId": "sig_019d0ab2-94e1-78eb-b311-ee4781bb97a5", "userId": "usr_rgQjIvToyj7hzVC8cTcJNwSO5Szm1jJp" } ``` Save this response. You need it for the next step. ## Import the hot share into OpenSigner Open the OpenSigner sample UI and find the **Import Share (Hot Storage)** section. Paste the full JSON response from the previous step into the text area and click **Import Share**. The sample UI automatically associates the imported share with the currently logged-in user and calls the `POST /v2/accounts/import-share` endpoint on your hot storage server. :::info The `signerId` and `userId` fields from the Openfort response are preserved during import. The `userId` maps to the original Openfort user and is stored as migration metadata for passkey recovery (see [Recover the key](#recover-the-key)). ::: ## Create a user in Shield Before importing the cold share, you need a user record in Shield that maps to your OpenSigner authentication service user. First, list the available providers to find the one your project uses: ```shell curl 'http://localhost:7053/project/providers' \ -H 'X-API-Key: {{SHIELD_API_KEY}}' \ -H 'X-API-Secret: {{SHIELD_API_SECRET}}' ``` Then create a user with the chosen provider. The `external_user_id` must match the user ID from your OpenSigner authentication service, not the Openfort user ID: ```shell curl -X POST 'http://localhost:7053/user' \ -H 'X-API-Key: {{SHIELD_API_KEY}}' \ -H 'X-API-Secret: {{SHIELD_API_SECRET}}' \ -H 'Content-Type: application/json' \ -d '{ "external_user_id": "{{AUTH_SERVICE_USER_ID}}", "provider_id": "{{PROVIDER_ID}}" }' ``` Save the Shield user ID from the response. You need it in the next step. ## Export the cold share from Openfort's Shield Export the encrypted cold share using the signer ID from the hot share export: ```shell curl 'http://localhost:8080/shares/migration/export/{{SIGNER_ID}}' \ -H 'X-API-Key: {{OPENFORT_SHIELD_API_KEY}}' \ -H 'X-API-Secret: {{OPENFORT_SHIELD_API_SECRET}}' ``` The response contains the encrypted share and its encryption metadata: ```json { "secret": "ni6aJfq/mgntiAJ72r0G6yznkaj4Rp6/L/8z...", "entropy": "passkey", "reference": "sig_019d0ab2-94e1-78eb-b311-ee4781bb97a5", "storage_method_id": 0, "passkey_reference": { "passkey_id": "-XEAtwEmwyg-p4Ors9sLOmZgp2Y", "PasskeyEnv": { "name": "Chrome", "os": "macOS", "osVersion": "10.15.7", "device": "Desktop" } } } ``` The `entropy` field indicates how the share was encrypted on Openfort (for example, `passkey`, `user`, or `project`). You need to use the same recovery method when recovering the key in OpenSigner. If the Openfort export shows `password` as the entropy value, the sample UI automatically converts it to `user` (the value Shield expects for password-based entropy). ## Import the cold share into OpenSigner Add the Shield user ID from step 3 to the exported cold share data: ```json { "secret": "ni6aJfq/mgntiAJ72r0G6yznkaj4Rp6/L/8z...", "entropy": "passkey", "reference": "sig_019d0ab2-94e1-78eb-b311-ee4781bb97a5", "storage_method_id": 0, "passkey_reference": { "passkey_id": "-XEAtwEmwyg-p4Ors9sLOmZgp2Y", "PasskeyEnv": { "name": "Chrome", "os": "macOS", "osVersion": "10.15.7", "device": "Desktop" } }, "userId": "{{SHIELD_USER_ID}}" } ``` In the sample UI, paste this combined JSON into the **Import Cold Storage (Shield) Share** section and click **Import Cold Storage Share**. This calls Shield's `POST /shares/migration/import` endpoint. ## Recover the key With both shares imported, you can reconstruct the private key. 1. In the sample UI, click **List Accounts** to verify the imported account appears. 2. Click **Recover (iFrame)** and enter the account ID. 3. Select the **Recovery Method** that matches the `entropy` value from the cold share export: * **Project Entropy** for `project` entropy * **Password** for `user` (or `password`) entropy * **Passkey** for `passkey` entropy 4. Provide the required credential (password or passkey authentication) and confirm. :::tip For passkey recovery of migrated accounts, OpenSigner uses the original Openfort user ID as the PRF seed. This is retrieved automatically from the migration metadata stored during hot share import. ::: Once recovery completes, the key is reconstructed in the iFrame and ready to sign transactions. :::: Key shares are exported from your existing [Openfort](https://www.openfort.io) project and migrated into your self-hosted instance. OpenSigner reconstructs the private key from any two of the three [Shamir's Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing) shares. ## See also * [Recovery Methods](/security/recovery-methods) * [Getting Started](/introduction/getting-started) * [Security overview](/security/overview) # Security overview The OpenSigner system is composed of multiple components that communicate over HTTP. Communication includes sensitive data such as key shares or access tokens, therefore **it is crucial to ensure communication is secured through TLS** and HTTPS is enforced. ## Glossary | Term | Definition | |---|---| | **End User** | The final user of the wallet (for example, a player in a game). | | **Developer / Project Owner** | The entity building the application (for example, You). Controls the project secrets. | | **Host / Operator** | The entity operating the infrastructure (for example, Openfort or You in self-hosted). | | **Auth Provider** | The system validating user identity (for example, Openfort Auth, Google, Custom OIDC). | | **Shield (Cold Storage)** | Component that stores encrypted recovery shares. | | **Hot Storage** | Component that stores frequently accessed shares. | | **iFrame** | Client-side component running in the user's browser that reconstructs keys. | ## Trust model Understand the root of trust at each point in the system. The key is split into three shares; two of them are enough to reconstruct the key. Once a user logs into a device, the three shares are stored in: * **User's device**: the domain-protected storage of the browser. * **Hot storage**: users can access this share through an access token granted by the **Auth Provider**. * **Cold storage** (Shield): users can access this share provided a request is made with valid entropy and a valid token is issued by the **Auth Provider**. The system relies on the following roots of trust: ### Auth provider (authentication service) The **Auth Provider** is responsible for validating the user's identity and granting access tokens. It must be trusted to securely handle user credentials and issue tokens that can be used to access shares. While the authentication service API is provided, each implementation is tied to each project's specific needs, and needs to be done with care. The impact of a compromised authentication varies depending on the context: * If the attacker also has access to the user's device and the key was previously reconstructed there: * They can fully recover the secret because user authentication directly unlocks the hot share. * If the attacker only has the user's credentials but no device access, the outcome depends on how cold share recovery is handled: * With *user-based recovery*, an extra password or passkey (independent from the user's main credentials) is needed to decrypt the cold share. * With *automatic recovery*: * Without OTP: the attacker can recover the user's secret key by using the stolen credentials. * With OTP: user interaction is required to recover the secret, as the attacker would need to compromise the OTP method as well. :::tip[Best Practice] The safest way to protect a cold share is to use user-based recovery with a completely separate password/passkey that's unrelated to the primary authentication method. ::: ### Application logic Two out of three shares are enough to reconstruct the key. For this reason, the system includes audited, open-source implementations of the storage and management systems of two of the three shares. * **iFrame**: The iFrame stores shares (in domain-based browser storage) and handles splitting and reconstructing the keys. It is the only component that has access to the full, reconstructed key. * **Cold Storage**: The cold storage is the stepping stone for every login in a new device. The share it contains is encrypted with either user or project entropy. ### Environment Use trusted browsers, servers, and execution environments. ### Developer / project owner Users with **automatic recovery** trust the owner of the project (Developer) to hold the **Developer Encryption Part** securely. If the Developer loses this part, automatic recovery becomes impossible for registered devices. If the Developer exposes this part, the non-custodial guarantees of the **Host** are compromised. ### Transport Sensitive data, such as access tokens and key shares, travel over the network when transmitted from one component to another. It is vital to have and enforce secure communication channels, such as HTTPS, to prevent eavesdropping and tampering. Make sure to validate TLS certificates to prevent Man In The Middle (MITM) attacks. [HSTS](https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security) should be used for browser-oriented operations. ## Next steps * **[Custody and key reconstruction](/security/deployment-scenarios#custody-and-key-reconstruction)** - Backend reconstruction and non-custodial guarantees * [Recovery Methods](/security/recovery-methods) - Password, passkey, and automatic recovery options * [Deployment Scenarios](/security/deployment-scenarios) - Hosting configurations and their security implications * [Threat Analysis](/security/threat-analysis) - Potential attack vectors and mitigations Transport security relies on [TLS 1.3](https://datatracker.ietf.org/doc/html/rfc8446). # Recovery methods The three supported recovery methods are *automatic*, *password* and *passkey* based recovery. Each has different security considerations. Each refers to how the recovery share is encrypted, and how the user can access it. ## Password-based recovery In password-based recovery, the recovery share is encrypted with a user-provided password. Recovery share encryption and decryption happen in the iFrame, making the user the sole owner of the entropy guarding the share. Check out the [**password-based signup**](/actions/signup#password-recovery) section for more details. :::info[Encryption Details] When setting a recovery password, the iframe uses the open-source [@openfort-xyz/crypto-js](https://github.com/openfort-xyz/crypto-js) library to first derive a secure key. The key derivation algorithm is `Argon2` with `12` iterations using `64MiB` of memory and a `128` bit long salt. A `256` bit length encryption key is then derived and used to encrypt the cold share. The cold share is encrypted using `AES-CBC` with the previously derived key. The Initialization Vector (IV) is generated by the iframe and is `128` bits of length, too. ::: ## Passkey recovery Passkey recovery works similarly to password-based recovery: an encryption key is derived from user input and shares are encrypted and split following the same pattern. In passkey-based recovery the cold share plaintext never leaves the client, neither does the derived encryption key. OpenSigner stores the following information: * The internal passkey ID, issued by the passkey authenticator * The encrypted (cold) share * Some environment-related information (browser name, OS, OS Version and Device information) :::info[Encryption Details] The cold share is encrypted using AES256 in CBC mode. The encryption key is derived using the Pseudo Random Function (PRF) extension, available for most modern passkeys. This extension allows an authenticated user to derive encryption keys. As in password based recovery, the IV is 128 bits of length and is generated by the iframe itself. ::: ## Automatic recovery In automatic recovery, key encryption and decryption happen in the cold storage. While providing the benefit of not requiring the user to remember a password, it introduces some risks: ### Ownership risk If the entity in control of the cold storage (which contains one of the two shares required to reconstruct the encryption key) is also the one in control of the other encryption key shares, it can access the full recovery share. This, when combined with control over one of the other two shares, allows the entity to reconstruct the key. ### Network risk The raw encryption key travels over the network from the iFrame to the cold storage and can be intercepted. Mitigate this with proper network security measures. Check out the [**automatic recovery signup**](/actions/signup#automatic-recovery) section for more details. ### Three key shares vs two encryption parts #### System 1: key shares (Shamir's Secret Sharing) The **user's private key** is divided into 3 shares: 1. **Device Share**: Stored in the browser's localStorage/IndexedDB. 2. **Hot Share**: Stored in the Host's Hot Storage (such as Openfort). 3. **Cold Share**: Stored in the Host's Shield (Cold Storage). :::tip You need **2 of 3** shares to reconstruct the private key. ::: #### System 2: encryption parts (project encryption key) The **Cold Share is ENCRYPTED**. To decrypt it, you need the Project Encryption Key, which is also divided: 1. **Developer Encryption Part**: Held by the Developer (You). Created once and must be stored securely. 2. **Shield Encryption Part**: Stored in the Shield database (managed by the Host). :::tip You need **BOTH** parts to decrypt the Cold Share. ::: ### Non-custodial in cloud hosting For a Cloud Host (like Openfort) to reconstruct a user's private key, it would need 2 shares. * It has the **Hot Share** (1/3). * It has the **Cold Share** (2/3), **BUT** it is encrypted. * To decrypt the Cold Share, it needs the **Developer Encryption Part**, which **only the Developer holds**. Therefore, the Cloud Host cannot decrypt the Cold Share and remains with only 1 usable share (Hot Share), which is insufficient to reconstruct the private key. ### Secure usage: encryption sessions To avoid sending the **Developer Encryption Part** with every request (which would verify the "custody" rule but increase exposure risk), use **Encryption Sessions**. 1. Backend calls `POST /project/encryption-session` with the `encryption_part`. 2. Shield returns a temporary, one-time use `session_id`. 3. The iFrame uses this `session_id` to decrypt the Cold Share. 4. The session expires immediately after use. This ensures the critical secret (Developer Part) is not constantly exposed on the network. :::info[Encryption Details] The cold storage implementation generates an encryption key for the recovery share, splits it into three (one is kept by the cold storage, one given to the caller, the last one deleted), then uses AES-GCM (Advanced Encryption Standard in Galois/Counter Mode) to store one of the shares in a database. The nonce, or IV, is 96 bits of length in this case, as recommended for this particular mode of AES. As in Password Recovery Mode, 256-bit length keys are used. This key is generated and split in shield. The key is generated using a secure RNG (golang's `crypto/rand`). ::: ## OTP verification for automatic recovery :::warning[Critical Security Consideration] In automatic recovery without OTP, the backend has access to both hot storage and cold storage. Using a valid user JWT, the system can coordinate access to these shares and reconstruct a user's private key on the backend. **No additional user-held secret is required beyond standard user authentication.** ::: ### Why OTP is essential To enhance the security of automatic recovery, you can enable OTP verification for your Shield project. When enabled, Shield requires an OTP to create an encrypted session for share decryption. The OTP is sent to the user's contact information (either email or phone number). This reduces the control that the cold storage host has over the stored shares. ### How OTP prevents backend reconstruction | Scenario | Backend Can Reconstruct Key? | User Action Required? | |----------|------------------------------|----------------------| | Automatic recovery **without OTP** | ✅ Yes | ❌ No | | Automatic recovery **with OTP** | ❌ No | ✅ Yes - must provide OTP | | Password-based recovery | ❌ No | ✅ Yes - must provide password | | Passkey-based recovery | ❌ No | ✅ Yes - must authenticate passkey | ### Enable OTP When OTP is enabled: 1. User initiates a recovery/signing operation 2. Shield sends an OTP to the user's registered email or phone 3. User provides the OTP to the iframe 4. Only after OTP verification can the cold share be decrypted This ensures that even with valid authentication tokens, the backend cannot unilaterally access user keys without active user participation. ## Related * Apply these methods in [Create a key](/actions/signup) and [Recover a key](/actions/login). Passkey recovery is built on [WebAuthn](https://www.w3.org/TR/webauthn-2/). # Deployment scenarios Due to the modular nature of the system, users can run or implement their own components. Hybrid scenarios are possible where Openfort hosts some components while others are self-hosted. ## Scenario overview This section evaluates the following scenarios, where the components are hosted by a third party such as Openfort (**TP**) or self-hosted (**SH**): | Scenario | Cold Storage | Auth Service | Hot Storage | iFrame | |---|---|---|---|---| | [Scenario 1](/security/deployment-scenarios#scenario-1-fully-self-hosted) | **SH** | **SH** | **SH** | **SH** | | [Scenario 2](/security/deployment-scenarios#scenario-2-self-hosted-auth--cold-storage) | **SH** | **SH** | **TP** | **TP** | | [Scenario 3](/security/deployment-scenarios#scenario-3-self-hosted-hot-storage) | **TP** | **TP** | **SH** | **TP** | | [Scenario 4](/security/deployment-scenarios#scenario-4-self-hosted-cold-storage) | **SH** | **TP** | **TP** | **TP** | | [Scenario 5](/security/deployment-scenarios#scenario-5-self-hosted-auth-service) | **TP** | **SH** | **TP** | **TP** | | [Scenario 6](/security/deployment-scenarios#scenario-6-fully-hosted) | **TP** | **TP** | **TP** | **TP** | ### Scenario 1: fully self-hosted | Cold Storage | Auth Service | Hot Storage | iFrame | |---|---|---|---| | **SH** | **SH** | **SH** | **SH** | > Fully self-hosted. This scenario relies on the hosting party implementing both the Authentication Service and the Hot Storage. Developers can *know* they're running unaltered builds of the Openfort components by checking the attestations, as explained in the [attestation section](/security/system-integrity#attestation). On that note, developers can enforce execution of this image by requiring attestations in their policies, as in this Google Cloud example that [requires attestation](https://cloud.google.com/binary-authorization/docs/key-concepts#evaluation-modes). Developers may also opt to go one step further and make some of their configuration public to show that attestation requirements are in place in their infrastructure. #### Noteworthy risks * **Execution environment**: The hardware and the OS running the storage services (and the DBs they rely on) have access to the processes' memory and could extract sensitive data such as key shares from the programs' memory. It is essential to run them in trusted, secure environments. For instance, when running the components in GKE make sure to use [confidential GKE nodes](https://cloud.google.com/kubernetes-engine/docs/how-to/confidential-gke-nodes). The underlying storage used by the DBs should also be protected, to prevent extraction and brute-forcing of stored keys. If using Cloud Hosts, encrypt storages with self-managed keys, such as GC's [CMEK](https://cloud.google.com/kubernetes-engine/docs/how-to/using-cmek). * **Communication**: Enforce and validate TLS encryption in every communication happening between two components. If services are running on the same machine, handle TLS certificates appropriately, use some proxy that supports TLS such as [Envoy](https://www.envoyproxy.io/), or use [Unix Domain Sockets](https://man7.org/linux/man-pages/man7/unix.7.html). The latter are still vulnerable to eavesdropping from the same machine, but have a reduced attack surface compared to TCP sockets. * **Total Asset Ownership**: Since the hosting party controls **all** the Keys components, they can also decrypt cold shares if those belong to a project they created. The hosting party has access to both encryption shares and to the encrypted cold shares stored in the cold storage. Thus, **projects must be registered and handled by third parties unrelated to the hosting party.** All of the next scenarios load their iframe from a third party, which could have been tampered with. This is not an issue in Scenario 1, as the iframe is loaded from the same origin as the rest of the components and served by the hosting party. ### Scenario 2: self-hosted auth + cold storage | Cold Storage | Auth Service | Hot Storage | iFrame | |---|---|---|---| | **SH** | **SH** | **TP** | **TP** | Developers rely on a third party to host their hot storage and their iframe. The main risk here is the iframe being tampered with, allowing attackers to capture passwords and secrets on the client's side. As mentioned before, iframe builds are attested and those feature derived checksums of the static assets it provides. The iframe is meant to be called via RPC methods from another app, so it is possible for both developers and end users to verify if the obtained static assets' checksums match those provided by the official build logs. ### Scenario 3: self-hosted hot storage | Cold Storage | Auth Service | Hot Storage | iFrame | |---|---|---|---| | **TP** | **TP** | **SH** | **TP** | The greatest security concerns in this scenario are: * **Access token forgery**: the third party (TP) could forge access tokens in the name of the user, and use them to access the cold storage.\ Shares are still encrypted with user entropy, which brings us to the next point. * **Share or encryption key bruteforcing**: either by forging tokens or accessing cold storage directly, the third party could try to decrypt the encrypted keys through brute-forcing. Scenarios in which the party controlling the cold storage is also responsible for automatic recovery share keeping, password-based recovery provides more protection than automatic recovery since the Host has access to all the required entropy to decrypt the cold storage share. ### Scenario 4: self-hosted cold storage | Cold Storage | Auth Service | Hot Storage | iFrame | |---|---|---|---| | **SH** | **TP** | **TP** | **TP** | This is a common scenario: the implementations provided by Openfort are self-hosted, while the ones defined as unimplemented APIs are hosted by a third party such as Openfort. The biggest risk in this scenario is the third party forging access tokens in the name of the user, and accepting them from the hot storage; as they implement and control both. Unlike in Scenario 3, the hot shares are not encrypted with user entropy which makes them vulnerable to access token forgery. ### Scenario 5: self-hosted auth service | Cold Storage | Auth Service | Hot Storage | iFrame | |---|---|---|---| | **TP** | **SH** | **TP** | **TP** | In this scenario, the third party controls the cold and hot storages. Combined with the brute-force risk mentioned in Scenario 3, this can be enough to reconstruct the users' private keys. ### Scenario 6: fully hosted > Fully hosted by a third party, such as Openfort. | Cold Storage | Auth Service | Hot Storage | iFrame | |---|---|---|---| | **TP** | **TP** | **TP** | **TP** | In this scenario, a single third party entity (Openfort) is responsible for all components. Because it hosts both the hot and cold storage, it is necessary to encrypt at least one of those two shares to prevent the host from accessing the full key. There are two safe approaches: 1. **User entropy**: only the user knows a password that is required to decrypt the cold storage share. 2. **Automatic recovery with OTP**: there is an encryption key; split between the user and the cold storage, that is used to encrypt and decrypt the cold storage share. The cold storage is temporarily granted access to the user's encryption share through a one-time access method, invoked by the user. The point of both approaches is the same: make user action a requirement to access the key. Making encryption key shares a one-time access thing in the cold storage, as well as the final key a one-time access thing in the iframe, has the objective of preventing key usage without user action. :::important[OTP Recommendation for Automatic Recovery] Third party Hosts that manage users' automatic recovery and the authentication service could, in theory, forge access tokens representing users and gain access to the recovery shares of those who have automatic recovery configured. Users should be made aware of this risk when configuring automatic recovery. **Using OTP is strongly recommended to protect user accounts in this scenario.** ::: Another important aspect to take into account when using automatic recovery is who owns what resources. If the organization in charge of the cold storage starts a project within it, the organization can reconstruct the project-wide encryption key on their own. Projects must be managed by someone who doesn't directly control the cold storage to avoid this scenario. ## Custody and key reconstruction ### Backend access to key shares **In automatic recovery mode without OTP, the answer depends on the hosting configuration.** * **Scenario 6 (Fully Hosted by a Host such as Openfort): No.** The Host (such as Openfort) holds the *Shield Encryption Part*. You (Developer) hold the *Developer Encryption Part*. As long as the Developer does not expose their part to the Host, the Host cannot decrypt the Cold Share. The Host has 1 share (Hot) + 0 usable shares (Encrypted Cold) = 1 share. **Insufficient.** * **Scenario 1 (Fully Self-Hosted): Yes.** If you host everything yourself, you hold the Hot Share + Shield Encryption Part + Developer Encryption Part. You (the Host/Developer) have full custody. **In password/passkey recovery or automatic recovery with OTP: No.** * **Password recovery**: Cold share is encrypted with user's password (client-side); backend never has the plaintext * **Passkey recovery**: Cold share encryption key is derived client-side via PRF extension; backend never has access * **Automatic + OTP**: OTP verification is required, which requires active user participation that cannot be forged ### Protection mechanisms for non-custodial deployments | Protection Mechanism | How It Works | Backend Access Blocked? | |---------------------|--------------|------------------------| | **Password Recovery** | User-provided password encrypts cold share client-side | ✅ Yes | | **Passkey Recovery** | PRF extension derives encryption key on user device | ✅ Yes | | **Automatic (Cloud)** | Encryption key split between Host (such as Openfort) and Developer (You) | ✅ Yes (if Developer Part is secure) | | **OTP/OTP for Automatic** | OTP sent to user's email/phone required for decryption | ✅ Yes | | **Split Hosting** | Hot and cold storage hosted by different, mutually-untrusting parties | ✅ Yes | ### Open-source custody model **The open-source configuration is custodial by default when you host everything yourself.** In a fully self-hosted setup (Scenario 1): 1. You control Hot Storage. 2. You control Shield (Cold Storage). 3. You control the Developer Encryption Part. 4. \= You can decrypt everything. **To achieve non-custodial guarantees in Self-Hosted:** * Use **password-based recovery** (recommended for maximum security), OR * Use **passkey-based recovery**, OR * Enable **OTP/OTP verification** for automatic recovery, OR * Run Shield in a **TEE (Trusted Execution Environment)** where the encryption keys are not extractable even by you. :::note[MiCA and Custody Definitions] Under regulatory frameworks like MiCA, a platform that is technically capable of reconstructing or exporting a user's private key—even if gated by authentication and frontend flows—may be considered custodial. The open-source OpenSigner configuration, when using automatic recovery without OTP, meets this technical definition of custody because the platform can access both shares needed for key reconstruction. **For non-custodial regulatory classification:** Implement one of the protection mechanisms listed above to ensure the platform cannot unilaterally reconstruct user keys. ::: # Threat analysis This section covers potential attack vectors against OpenSigner components and recommended mitigations. ## Tampering risks If the following components are tampered with by a third party or compromised while under the developer's control, these issues can arise: ### iFrame compromise The iframe is the only component with access to the full, reconstructed key. If the iframe is compromised, the attacker could use the key to impersonate the user and interact with the chain on their behalf. **Mitigations:** * Verify iframe checksums against official build logs * Use [attestation verification](/security/system-integrity#attestation) to ensure build integrity * Load iframe from trusted, self-hosted origin when possible ### Auth service compromise Having a compromised auth service has, besides the usual implications, some risks *if the storages are also compromised*. The auth service could forge an access token and have the hot or cold storage accept them. Users could then attempt to perform an operation unaware of the forgery, and provide their recovery share entropy to the attacker when trying to log into a new device. **Mitigations:** * Use short-lived access tokens with strict validation * Implement token binding to specific operations * Monitor for unusual authentication patterns ### Hot storage compromise If the hot storage is compromised or tampered with, attackers have access to one of the two shares required to reconstruct the key. **Mitigations:** * Hot storage alone is insufficient for key reconstruction * Requires compromise of cold storage or device share for full attack * Encrypt database storage with self-managed keys (CMEK) ### Cold storage compromise The risk of a compromised cold storage is, in isolation, lesser than that of the hot storage because the cold storage share is encrypted with user entropy, and access to the cold storage alone is not enough to reconstruct the recovery share. When combined with other compromised components, the risk increases significantly. **Mitigations:** * Use password or passkey recovery (user-held secrets) * Enable OTP for automatic recovery * Run cold storage in TEE with non-extractable KMS ## Attack scenarios ### Credential theft + no device access **Attack:** Attacker obtains user's login credentials but doesn't have physical access to their device. | Recovery Method | Outcome | |-----------------|---------| | Password-based | ❌ Attack fails - attacker needs recovery password | | Passkey-based | ❌ Attack fails - attacker needs passkey device | | Automatic (Self-Hosted, No OTP) | ⚠️ Attack succeeds - credentials unlock all shares if Admin holds all keys | | Automatic (Cloud) | ❌ Attack fails - Admin holds Developer Part, attacker lacks it | | Automatic (with OTP) | ❌ Attack fails - attacker needs OTP | ### Malicious host **Attack:** The entity hosting OpenSigner components attempts to access user keys. | Hosting Configuration | Outcome | |----------------------|---------| | Single Host operates all + holds ALL keys (Self-Hosted default) | ⚠️ Host can reconstruct keys | | Cloud Host (such as Openfort) + Developer holds Encryption Part | ❌ Host has only 1 usable share (Hot) | | Single Host + password/passkey recovery | ❌ User entropy protects cold share | | Single Host + automatic + OTP | ❌ OTP required for cold share access | | Split hosting (different Hosts) | ❌ No single party has both shares | ### Token forgery **Attack:** Auth service operator forges tokens to access shares. **Protection:** Even with forged tokens: * Password recovery: cold share requires user's password * Passkey recovery: cold share requires user's passkey * Automatic + OTP: cold share requires user's OTP ## Best practices * **Always validate tokens**: expiration, issuer, and contents. * **Don't log sensitive data**, such as access tokens or key shares. * **Enforce valid TLS** on all communications. * **Run services in Trusted Execution Environments (TEE)** when possible. * **Set TTLs for access tokens** to limit their validity period. * **Use password or passkey recovery** for maximum security. * **Enable OTP for automatic recovery** to prevent backend reconstruction. * **Separate project ownership** from infrastructure hosting. * **Monitor audit logs** for unusual access patterns. * **Regularly rotate** service credentials and API keys. This analysis follows established practices such as [OWASP threat modeling](https://owasp.org/www-community/Threat_Modeling) and [STRIDE](https://learn.microsoft.com/en-us/azure/security/develop/threat-modeling-tool-threats). ## Related * Read the [security overview](/security/overview) for the trust model. * Choose a configuration in [deployment scenarios](/security/deployment-scenarios). # System integrity This page covers how to verify that OpenSigner components are authentic and haven't been tampered with. ## Attestation Openfort's published images are attested through [Cosign](https://github.com/sigstore/cosign). GitHub generates a unique key for each workflow run, and signs the images built by the workflow. Openfort has no access to these keys, ensuring the signed builds have not been tampered with or created with any method other than the automated release workflows. :::warning[Which images are attested] Attestation covers the images Openfort **publishes** from their own release workflows — `shield` (cold storage) and `iframe`. `auth_service` and `hot_storage` are **not** published as attested images. They are reference implementations that you build yourself from this repository, so there is no upstream signature to verify: their provenance is your own build pipeline. If you deploy them, sign your images in your own CI and verify those signatures at deploy time. Verifying an Openfort signature is not a check you can perform on a component you compiled. ::: ### Verify images To validate that an image was built, published, and signed by the Openfort CI workflows, users can run: ```bash gh attestation verify \ oci://: \ --repo '' \ --signer-workflow ' /' ``` ### Example: verify Shield image ```bash gh attestation verify \ oci://docker.io/openfort/shield@sha256:61fb0ac9b409ebcff5c10910708774e4a1bcfda6818ddc4b2f28330f12d7773c \ --repo openfort-xyz/shield \ --signer-workflow openfort-xyz/shield/.github/workflows/docker-image.yml ``` ### Verify by tag Although digests are the recommended way to refer to images, images can also be verified by tag: ```bash gh attestation verify \ oci://docker.io/openfort/shield:v0.2.6 \ --repo openfort-xyz/shield \ --signer-workflow openfort-xyz/shield/.github/workflows/docker-image.yml ``` ### Alternative: Rekor logs Alternatively, images can be verified by manually checking the Rekor logs and searching for the image digest, for example: [https://search.sigstore.dev/?hash=61fb0ac9b409ebcff5c10910708774e4a1bcfda6818ddc4b2f28330f12d7773c](https://search.sigstore.dev/?hash=61fb0ac9b409ebcff5c10910708774e4a1bcfda6818ddc4b2f28330f12d7773c). ## Derived checksums The iframe serves static assets, and checksums for these assets are generated at build time using SHA-256. Because iframe builds are attested, these checksums are also transitively attested. The published checksums can be compared to those computed by the user after downloading the static assets. If any mismatch is found, the iframe's execution can be aborted. ### Example build output Here's an example of the output generated when building the iframe: ``` 876766b4e80133fd490603e073d3567425b88794828a9292104244c9e40875ed /usr/share/nginx/html/50x.html 78fe0c953e0235a6ce563d728eaacbb9a4630cbd22831523d74017820a5c067c /usr/share/nginx/html/index.html b209a972c9f0dcc4354098df2943d21b0daa6a49486c07f2cd265d6274b0f3c2 /usr/share/nginx/html/assets/index-jgtWx_p5.js 713b113fde9db05faa5b320e52ed7a5f0693faa71262ad55760d65b062103bc7 /usr/share/nginx/html/favicon.ico ``` This means that the user should expect having only one javascript file called `index-jgtWx_p5.js` whose `sha256sum` is `b209a972c9f0dcc4354098df2943d21b0daa6a49486c07f2cd265d6274b0f3c2`, which can be verified client-side anytime. This is also an exhaustive list of all the assets the user should expect seeing on their side. ### Limitations :::danger[Important Caveat] This checksum does not prove that the `iframe` is intact. The `iframe` still relies on an nginx to provide such static assets and it's still on the client to retrieve and check those hashes. Attackers can still divert traffic to unwanted places or load additional assets not covered by the build-time checksum generation. ::: ### Client-side verification To verify checksums client-side: 1. Fetch the static assets from the iframe origin 2. Compute SHA-256 hash of each file 3. Compare against the published build checksums 4. Abort iframe execution if any mismatch is detected ```javascript // Example verification approach async function verifyAssetChecksum(url, expectedHash) { const response = await fetch(url); const buffer = await response.arrayBuffer(); const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); const hashHex = Array.from(new Uint8Array(hashBuffer)) .map(b => b.toString(16).padStart(2, '0')) .join(''); return hashHex === expectedHash; } ``` ## Related * Read the [security overview](/security/overview) and [threat analysis](/security/threat-analysis). * Pick a configuration in [deployment scenarios](/security/deployment-scenarios). # Create a key To create a new key with OpenSigner, users call the `create()` method on the iFrame. The iFrame generates a new private key and splits it into three shares using threshold cryptography. Two shares are distributed to hot storage and cold storage respectively, while the third share is stored locally in the localStorage. The signup process depends on the recovery method: * **Password Recovery**: User provides a password to encrypt the cold share client-side * **Automatic Recovery**: Cold share is encrypted server-side using project entropy * **Passkey Recovery**: User creates a passkey to derive an encryption key for the cold share :::info The signup diagrams show data being moved inside the process or to local storage with continuous lines, and the data potentially being transmitted over a network with dashed lines. Connection legend ::: ## Password recovery When using password-based recovery, the user provides the entropy used to encrypt the recovery share. This ensures the system remains non-custodial and users control their keys. ![Sign up user with automatic recovery](/diagrams/signup_password.svg) ## Automatic recovery When using *automatic recovery*, the entropy is managed by the cold storage service. To secure the recovery share, an encryption key is generated in the cold storage, which is then split into 2 shares with a required quorum of 2 for reconstruction. One share is kept by the cold storage, and another one is given back to the developer. The developer must secure this encryption share at all times, and it should never be exposed on the client side. When a request to secure a new recovery share is made, the developer must `POST` to the cold storage `/project/encryption-session` endpoint with the **encryption share**. This endpoint returns an encryption session ID, which the developer must provide to the user during the signup process. This session ID is valid for one-time use. This adds complexity but allows users to recover their keys without remembering a password. For the system to remain non-custodial, the **Developer** (holding the encryption part) must differ from the **Cold Storage Host**. :::danger If the same entity controls the cold storage and the **Developer Encryption Part**, the system becomes custodial, as the entity can access the recovery share. ::: ![Sign up user with automatic recovery](/diagrams/signup_automatic.svg) ### OTP with automatic recovery You can enable OTP verification for your Shield project to enhance the security of automatic recovery shares. The diagram above remains valid. The key difference is that during key reconstruction, Shield requires an OTP when creating a new encrypted session. The OTP is sent to the user via SMS or email. This ensures that the cold share cannot be accessed for key reconstruction without user interaction. ## Passkey recovery OpenSigner uses the passkey Pseudo Random Function (PRF) extension to derive an encryption key to symmetrically encrypt/decrypt the cold share. The user needs only to follow their authenticator's flow for passkey creation and validation. OpenSigner remembers which passkey it should ask for whenever a user wants to recover their cold share. Both the passkey's private key and the cold share are safe in this scenario, too: * The passkey's private key cannot leave the authenticator device * The cold share is encrypted and decrypted on the client side ## Related * Next, [recover a key](/actions/login) on a new device and [sign an operation](/actions/operation). * Compare the options in [Recovery methods](/security/recovery-methods). Passkey recovery uses the [WebAuthn](https://www.w3.org/TR/webauthn-2/) PRF extension; see [passkeys.dev](https://passkeys.dev/) for an overview. # Recover a key Before recovering a key, the user must call hot storage to retrieve their list of accounts and select the one to recover. Once selected, pass the account UUID to the iFrame, which handles the recovery process. The process differs depending on the recovery method: * **Password Recovery**: User provides a password to decrypt the cold share * **Automatic Recovery**: Cold share is decrypted server-side using project entropy * **Passkey Recovery**: User authenticates with a passkey to derive the decryption key ## Password recovery The user recovers the key through the iFrame. The iFrame attempts to reconstruct the key and fails because the local share is missing. This share is stored on each device after the user recovers it for the first time on that device. Instead, the iFrame fetches the hot and cold shares with the JWT token it obtains from the auth service, reconstructs the key, splits it again, and: * Discards the cold share. * Stores the local share on the device. * Stores the hot share in the hot storage. The diagram below shows this process in detail. ![Login user with password-based recovery](/diagrams/login_new_device_password.svg) ## Automatic recovery :::info `Admin` and `User` can be the same entity, though this defeats the purpose of automatic recovery. Typically, `Admin` is the application developer, and `User` is the end user. ::: ![Login user with automatic recovery](/diagrams/login_new_device_automatic.svg) The user can now use this device without accessing the cold storage again by using the local and hot shares to reconstruct the private key. The diagram doesn't show the private key reconstruction in the cold storage. The following section explains it in detail. The cold storage has the cold share, but it is encrypted with a key it has no access to. The key used to encrypt the cold share was split into shares and deleted after its first usage. The cold storage kept one of these shares, while the admin kept the other share. When the admin calls the cold storage share retrieval endpoint, it provides its share as a one-time input for reconstructing the encryption key and decrypting the cold share. To enforce this one-time usage, the cold storage deletes the encryption key share passed by the admin after using it once. ![Cold share reconstruction](/diagrams/enc_key_reconstruction.svg) ### OTP with automatic recovery The flow is the same as automatic recovery above. The only difference is in encrypted session creation—it requires action from the user. The diagram below shows only the encrypted session creation flow. ![Login user with automatic recovery and OTP](/diagrams/login_new_device_automatic_otp.svg) As shown in the diagram, the admin must request an OTP for the user before proceeding with encrypted session creation. If a session is created without the OTP, the cold storage does not return the share to the iFrame, causing the entire key recovery process to fail. ## Passkey recovery When cold shares are encrypted using passkeys, OpenSigner stores the necessary information for it to know which passkey it should ask for. If a user wants to retrieve their cold share, they are prompted to authenticate with the passkey they used to create the account. Most passkey authentication providers still show some kind of prompt even if they don't find the passkey within the local authenticator, such as a picture with a QR code if the passkey was created using a phone. Once properly authenticated, no further interaction is required from the user: both the PRF generation and the key derivation/share encryption happen under the hood, leaving the unencrypted cold share available for full key recovery. ## Related * Start with [Create a key](/actions/signup), then [Sign an operation](/actions/operation). * Compare the trade-offs in [Recovery methods](/security/recovery-methods). Passkey recovery is built on [WebAuthn](https://www.w3.org/TR/webauthn-2/); automatic recovery can use a time-based one-time password ([RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238)). # Use the key After [creating a key](/actions/signup) and [recovering it on a device](/actions/login), the user can sign transactions and messages. This page explains how the signing flow works. ## How signing works All signing operations happen inside the iFrame. The private key is never exposed to the host application or any external service. The flow consists of three steps: 1. The iFrame retrieves the hot share from Hot Storage and the cold share from Cold Storage (Shield). 2. The iFrame reconstructs the private key in memory using the two shares. 3. The iFrame signs the requested data and immediately discards the private key. This ensures the private key exists in memory only for the duration of the signing operation. ## Signing flow in detail ### Share retrieval The iFrame fetches the user's shares from both storage components: * **Hot share**: retrieved from the Hot Storage service via the `POST /v2/devices/recover` endpoint, using the user's JWT for authentication. * **Cold share**: retrieved from Shield (Cold Storage), also authenticated via the user's JWT. Both shares must be available to reconstruct the private key. If either share is missing, the signing operation fails. ### Key reconstruction Once both shares are available, the iFrame uses Shamir's Secret Sharing to reconstruct the full private key. This happens entirely in the browser — no server ever sees the complete key. ### Signing and cleanup The reconstructed private key signs the requested payload (a transaction, a message, or typed data). After signing, the iFrame: 1. Discards the private key from memory. 2. Re-splits the key into new shares if the current device had no local share in browser storage. 3. Stores the new hot share in Hot Storage and keeps the local share in browser storage. This re-splitting ensures that the shares change after each use, limiting the window of exposure if any single share is compromised. ## Integration The host application communicates with the iFrame through the browser's [postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) API. The [iFrame sample](https://github.com/openfort-xyz/opensigner/tree/main/iframe/sample) demonstrates the complete flow, including: * Authenticating with the auth service to obtain an access token * Creating an iFrame instance * Requesting a signature for a message ## Security considerations * The private key never leaves the iFrame's execution context. * Shares are encrypted at rest in both Hot Storage (AES-256-GCM) and Cold Storage. * The key exists in memory only during the signing operation and is discarded immediately after. * Re-splitting after each use means compromising a previously captured share does not help an attacker. ## Related * First [create a key](/actions/signup) and, on a new device, [recover a key](/actions/login). # Authentication The authentication service is responsible for verifying users. The auth service, hot storage, and cold storage all share the `user` concept. A user is a `uuid`, and the owner of the data it has stored in the storages. When a user requests data from the storages, it must pass its user ID and access token. The storages then ask the auth service to verify that the access token belongs to the user, and only then return the requested data. The authentication service supports two types of authentication: * **First-party authentication**: The authentication service provided by Openfort. * **Third-party authentication**: An authentication service provided by an external provider such as Google, Apple, or GitHub. In both cases, the hot and cold storages expect a JWT token to be passed in the `Authorization` header or in a cookie field specified on the request itself. ## First-party authentication In this model, the implementer is fully responsible for user authentication. The current model uses email/password authentication. ## Third-party authentication Third-party authentication relies on OAuth 2.0 or OpenID Connect to verify user identities. Once the user authenticates, the third-party provider returns an access token. Rather than giving this token directly to users, implementers should map it to a new token generated by the authentication service (not the third party) and pass this token instead. This ensures users have access to the Keys service but can't impersonate the auth service. The auth service issues [JWT](https://datatracker.ietf.org/doc/html/rfc7519) access tokens and is built on [Better Auth](https://www.better-auth.com/). ## Related * See how [users and projects](/introduction/users) map to authentication providers. * Tokens gate access to [hot storage](/components/hot_storage) and [cold storage](/components/shield). # iFrame The iFrame is the core client-side component that handles all cryptographic operations securely within the user's browser. ## General overview The iFrame is embedded into the user browser, or into a React Native app. It is the component in charge of generating the private key, splitting it into shares, and storing them in their respective storage components; as well as fetching the shares and reconstructing the private key when required. Operations that use the private key all take place inside the iFrame, so the private key is never exposed to the outside world. The iFrame reconstructs the private key in memory and forgets it after each usage, ensuring that the private key is never stored in the browser and its in-memory lifetime is as short-lived as possible. The sample provided in the [iFrame sample](https://github.com/openfort-xyz/opensigner/tree/main/iframe/sample) shows how to: * log into the auth service to get an access token * create an iFrame instance * register a user, generating its private key to split it and store the shares * retrieve the shares, reconstruct the private key and sign a message ## How it works The iFrame is written in React and uses the [crypto-js](https://github.com/openfort-xyz/crypto-js), [openfort-js](https://github.com/openfort-xyz/openfort-js), and [shield-js](https://github.com/openfort-xyz/shield-js). The constructor expects the access token and the URL of the hot storage. The iFrame can be interacted with by using the browser [postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) API, which allows sending messages to the iFrame and receiving messages from it. The cold storage URL is configured when calling the methods that require it. After a successful reconstruction of the private key, if the current device had no local share in the [browser storage](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API), the key is split once again into *different* shares which are then stored in the local and hot storages. ## Related * Walk through [Create a key](/actions/signup), [Recover a key](/actions/login), and [Sign an operation](/actions/operation). * The iframe coordinates with [hot storage](/components/hot_storage) and [cold storage](/components/shield). # Hot storage The Hot Storage component is used to store "hot shares": shares that are required each time an operation is performed with the private key the "hot share" belongs to: log in, sign transactions, export the private key, and more. Unlike the [Cold Storage](/components/shield) component, which is only accessed when the user logs into a new device, the hot storage handles fast, frequent access. The hot storage doesn't include a production-ready implementation. A base implementation for development purposes is available under the `hot_storage/sample` directory. Implement your own version according to your needs. The sample implementation is written in Go, uses PostgreSQL to store data, and can be configured through the environment variables shown in the `docker-compose.yml` file at the root of the repository. ## How it works The Hot Storage links shares to a specific device, user, and auth provider, and stores them in a database. The user is validated against the specified auth provider using the configured [Auth Service](/components/auth). Users must specify the user ID, auth provider, and device ID when requesting shares, and prove their identity through a [JWT](https://www.jwt.io/) token issued by the specified auth service. The auth service must match the one configured when creating the share. Hot shares are not encrypted with user entropy. However, the sample implementation encrypts all shares at rest using AES-256-GCM before storing them in the database. This protects shares if the database is compromised. ### At-Rest Encryption The sample hot storage encrypts every share with a server-side key before writing it to PostgreSQL and decrypts it on read. The encryption uses AES-256 in GCM (Galois/Counter Mode) with a random 96-bit nonce per share. The stored value is `base64(nonce || ciphertext || GCM tag)`. The encryption key is configured through the `SHARE_ENCRYPTION_KEY` environment variable, which must be exactly 64 hex characters (32 bytes). Generate one with: ```shell openssl rand -hex 32 ``` :::warning Keep `SHARE_ENCRYPTION_KEY` secret. If this key is lost, all stored shares become unrecoverable. If it is compromised, an attacker with database access can decrypt every share. ::: Even with at-rest encryption, follow [best practices for database security](https://www.cybertec-postgresql.com/en/postgresql-security-things-to-avoid-in-real-life/) to ensure access is properly controlled. ## Specification The full specification for the request is available in the [API documentation](/apis/hot_storage), and a Postman collection with pre-configured calls is available at the [Postman Collection](/apis/postman). # Cold storage ## General overview The Cold Storage, or *Shield*, is where the cold share lives. It provides two main services: 1. Cold share storage 2. Cold share recovery Shield relies on the same authentication system as the Hot Storage. That is, an OIDC-compatible authentication system needs to be set up and specified for Shield to be able to recognize and validate users. ## Architecture Shield is an HTTP server exposing an API. This API is mostly used by iFrame, and no hand-crafted requests should be needed or done by any other HTTP client than that of iFrame's. There are a couple of exceptions for this: handling project and authentication providers. These two exceptions are explained in more detail in further sections. In any case, the API specifics can be found in [Shield's official repository](https://github.com/openfort-xyz/shield) `README.md`. Shield is fully written in Go and it relies on a PostgreSQL database for its persistence layer. All database interactions are done using Go's ORM, `gorm`. Database migrations are done via `goose`. The app's entrypoint is CLI-based. Its two main command branches are `db` and `server`. ``` 2025/08/04 09:07:16 INFO Starting OpenFort Shield Root command Usage: shield [command] Available Commands: completion Generate the autocompletion script for the specified shell db Database operations help Help about any command server Run the server Flags: -h, --help help for shield Use "shield [command] --help" for more information about a command. ``` The `db` command offers two sub-commands: `create-migration` and `migrate`. `create-migration` creates migrations reflecting the difference between what's modeled in Shield and what's available in the DB schema. This command **doesn't take indexing into account** so manually review all generated migrations. `migrate` applies all the migrations that are not present in the current target DB. The `server` command starts Shield. `server` also detects pending DB migrations and applies them. Shield's codebase is structured following a hexagonal architecture approach. That is, each layer is self-contained, and different layers communicate only through agreed-upon ports. When exploring the codebase, start in `server.go` and then go all the way down through whatever handler, service, and repository you need to look for. Shield also features mock entity repositories. This allows anyone to run shield's tests without having to have an actual database up and running. Tests can be run via `go test` or `gotestsum` as it's usual in Go projects. ## Prerequisites Shield requires a PostgreSQL database. Configure the following environment variables ``` # DB related fields, those do NOT have a default value DB_HOST= DB_PORT= DB_USER= DB_PASS= DB_NAME= # TLS mode for the database connection. Note the underscore: Shield reads # DB_SSL_MODE and defaults it to `disable`, so a misspelled name yields an # unencrypted connection rather than an error. Set `require` in production. DB_SSL_MODE= # URL to the hot storage, used as base URL for API OPENFORT_BASE_URL= # Shield's port, default is 8080 PORT= # Requests per second, default is 100 RPS= # Read timeout, default is 5s READ_TIMEOUT= # Write timeout, default is 10s WRITE_TIMEOUT= # Idle timeout, default is 15s IDLE_TIMEOUT= # CORS Max Age, default is 86400 CORS_MAX_AGE= # CORS extra allowed headers, empty by default CORS_EXTRA_ALLOWED_HEADERS= ``` `OPENFORT_BASE_URL` refers to the **Hot Storage**. Shield learns about the authentication service once an Authentication Provider is configured for a certain project. ## Versioning The provided `docker-compose.yml` pins Shield to a specific release image. The current version is `v0.3.2`: ```yaml image: ghcr.io/openfort-xyz/shield:v0.3.2 ``` Shield dropped MySQL support in `v0.3.0` and speaks only the PostgreSQL wire protocol from that release on. Earlier tags such as `v0.2.40` expect MySQL and will not start against the database this stack provides. Shield images are published to the GitHub Container Registry under `ghcr.io/openfort-xyz/shield`. To check for newer versions, visit the [Shield repository releases](https://github.com/openfort-xyz/shield/releases) or list available tags in the registry. When upgrading, review the release notes for breaking changes before updating the tag in your `docker-compose.yml`. After updating, restart the container — Shield automatically applies any pending database migrations on startup. :::warning[Shield creates its tables, not its database] Those migrations run *inside* `DB_NAME`; Shield does not create that database. `postgres/init.sql` creates it, but PostgreSQL only runs that script when it initialises a **fresh** data directory. On a volume that predates this stack's move to PostgreSQL the database is absent, and cold storage will restart-loop until it is created by hand: ```bash docker exec ofpostgres psql -U postgres -c 'CREATE DATABASE shield;' ``` Key shares held in a pre-`v0.3.0` MySQL deployment are not carried over by this change; there is no migration path between the two engines. ::: ## Deployment Shield is ready to be Dockerized. No environment variables or build args are needed for this step. A regular `docker build . -t xyz ...` command works. A Shield container can be then started with a regular `docker run` command. Environment variables can be either specified in the `run` command or by mounting a `.env` file. Shield starts its HTTP server on port `8080`, so whatever port mapping intending to make a Shield container reachable from the outside should use `8080` as the container port. Shield doesn't feature any kind of HTTPS support by itself. Secure communications must be enforced via load balancers and/or other front mechanisms. Shield can also run locally (for example, `go cmd/main.go server`), but this is not recommended for production. The provided Docker image expects the user providing the proper CLI commands. That is, the image can be used for either DB migrations or running the actual server. ## Security Shield doesn't implement any kind of HTTPS handling by itself: it needs to be done somewhere else and proper routing needs to be implemented in the corresponding load balancers. Same goes for cert validation, IP whitelisting, and more. Shield only cares about user and project authentication and it does so by delegating it to the external auth server. Secure communications are essential here: Shield communicates unencrypted shares to the iFrame. Any compromise in either endpoint or in the communication channel exposes the share to unauthorized parties. ## Core concepts ### Projects A project is a group of users. Each project also features an encryption key needed for automatic share recovery in case it's needed. ### Users As mentioned in other sections, the user is the core concept of Keys. Users are who store shares and might need them to recover their keys afterwards. A user belongs to exactly one project. ## Recovery methods ### Password recovery Shares can be encrypted/decrypted in `iFrame` based on user-originated entropy. **User-based encryption DOESN'T happen in Shield, it's fully client-side**. Shield only stores the encrypted share and blindly sends it back to the user along with its encryption parameters. Shield doesn't know, and can't know, if the share encryption was performed as stated in the encryption parameter set. Shield also **doesn't retrieve externally stored shares**. A share can be stored somewhere else (for now, Google Drive and iCloud). Shield only stores the reference to where the share is stored, but it's up to the client (that is, the iFrame) to recover the actual share. When this method is chosen the following happens: 1. The user introduces a password 2. An encryption key is derived from this password 3. This password can then be used to encrypt/decrypt their shares Both encryption and decryption happen in iFrame. Shield stores whatever the user sends to it along with the encryption parameters that have been used to perform such encryption. The encryption parameters are `salt`, `iterations`, `length` and `digest`. `digest` refers to the hash algorithm used by PBKDF2, not the digested secret itself or anything or the sort. Following the most common recommendations, [`salt` should be at least 128 bits long](https://csrc.nist.gov/pubs/sp/800/132/final) and [at least 600\_000 iterations should be used along with PBKDF2](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#:~\:text=If%20FIPS%2D140%20compliance%20is,provides%20no%20additional%20secure%20characteristics). `length` references the number of **bytes** of the resulting derived key, not bits. ### Automatic recovery Cold share recovery can be also done via project-wide entropy. In this case, **both encryption and decryption happen server-side** (understanding *server* as *shield*). When a project is created, an encryption key for this particular project is created and split between shield and the project. This key is reconstructed and used to encrypt/decrypt shares whenever the entropy source is the project. This type of share recovery can be enhanced with OTP (One-Time Password) verification. See [OTP for Automatic Recovery](/components/cold_storage/otp) for detailed documentation. ### Passkey recovery Cold shares can be encrypted using passkeys. Whenever a user wants to encrypt/decrypt their share the following happens: 1. The passkey authenticator prompts the user to authenticate themselves 2. If the authentication is successful, a 256-bit encryption key is derived 3. This encryption key is used to both encrypt and decrypt their cold share 4. Shield then remembers the internal passkey ID and certain environment details for that share #### Authentication process **OpenSigner does not rely on webauthn authentication ceremonies**. In other words, OpenSigner does not support issuing server-side challenges and validating them. As discussed in previous chapters, user authentication is done and managed by the external auth provider, and Shield uses it as its only source of truth regarding authentication. Shield still requires proper user authentication when storing and retrieving shares encrypted using passkeys. From here on, authentication refers to the process of the user successfully authenticating within the passkey's ecosystem (by introducing a PIN, biometrics, and so on). This authentication, if successful, makes the authenticator return the following: * A signed challenge (which we don't use) * A derived 256-bit encryption key #### Encryption key Modern passkeys support the Pseudo Random Function (PRF) extension. This extension allows the user to produce pseudo-random noise in a deterministic way using their private key (which never leaves the passkey's authenticator) and some seed. OpenSigner relies on this extension to produce a 256-bit encryption key for symmetric encryption of the cold share. Note that to use this PRF output, both the private key and the seed are needed. The seed can remain public and having *access* to the private key requires the user to be authenticated within the passkey's authenticator. OpenSigner uses the user's external ID as the fixed seed. Using PRF makes server-side challenges unnecessary: an attacker cannot benefit from an old signature since there's no verifier to do replication attacks against, and PRF outputs require being properly authenticated within the passkey's ecosystem. #### Encrypting and decrypting The derived 256-bit key is then used to symmetrically encrypt and decrypt the cold share. OpenSigner uses `AES-CBC` in this case. `AES-CBC` is sufficient since shares are of fixed size, rendering potential oracle padding attacks useless. Both the encryption and decryption happen client-side: Shield only sees and interacts with the encrypted contents of the share. Non-authenticated encryption is acceptable here: decrypting garbage causes further checks (such as private key to address derivation) to fail. #### What Shield stores Shield stores the encrypted contents of the cold share along with the internal passkey ID. By *internal passkey ID* we mean the identifier the passkey authenticator gave to that particular passkey. OpenSigner also stores the environment in which the passkey was created. This information is mainly extracted from the `User-Agent` token and is meant for both hinting and tracking purposes. ## API error codes Shield returns an HTTP status, an error code, and a descriptive message on failure: ```json { "message": "Error description", "code": "ERROR_CODE" } ``` ### OTP errors | HTTP Status | Error Code | Message | |-------------|------------|---------| | 429 | `OTP_RATE_LIMIT` | Rate limit exceeded to generate OTP | | 422 | `OTP_EXPIRED` | OTP is expired | | 400 | `OTP_INVALIDATED` | OTP invalidated after max failed attempts | | 400 | `OTP_INVALID` | Received otp is invalid | | 400 | `OTP_REQUESTED_BUT_NOT_SENT` | OTP was requested but not sent | | 428 | `OTP_MISSING` | OTP is required for this request | | 404 | `OTP_RECORD_NOT_FOUND` | OTP record not found for user | | 400 | `OTP_USER_INFO_MISSING` | Missing user information like email or phone number | | 400 | `OTP_NOT_SUPPORTED` | Project doesn't support OTP | | 409 | `OTP_ALREADY_ENABLED` | Project already has OTP enabled | ### Project errors | HTTP Status | Error Code | Message | |-------------|------------|---------| | 404 | `PJ_NOT_FOUND` | Project not found | | 409 | `EC_EXISTS` | Encryption part already exists | | 409 | `EC_MISSING` | The requested share have project entropy and encryption part is required | | 400 | `EC_INVALID` | Invalid encryption part | | 400 | `EC_INVALID` | Invalid encryption session | ### Share errors | HTTP Status | Error Code | Message | |-------------|------------|---------| | 404 | `SH_NOT_FOUND` | Share not found | | 409 | `SH_EXISTS` | Share already exists | ### User errors | HTTP Status | Error Code | Message | |-------------|------------|---------| | 404 | `US_NOT_FOUND` | User not found | | 404 | `US_EXT_NOT_FOUND` | External user not found | | 409 | `US_EXT_EXISTS` | External user already exists | | 400 | `USER_CONTACTS_MISMATCH` | User contact information mismatch | | 400 | `EMAIL_INVALID` | Provided Email is invalid | | 400 | `PHONE_INVALID` | Provided phone number is invalid | ### Provider errors | HTTP Status | Error Code | Message | |-------------|------------|---------| | 400 | `PV_UNKNOWN` | Unknown provider type | | 400 | `PV_MISSING` | Missing provider | | 404 | `PV_NOT_FOUND` | Provider not found | | 400 | `PV_CFG_INVALID` | Invalid provider config | | 400 | `PV_CFG_INVALID` | Missing key type | | 400 | `PV_CFG_INVALID` | Invalid PEM certificate | | 409 | `PV_CFG_INVALID` | JWK and PEM cannot be set at the same time | | 409 | `PV_EXISTS` | Custom authentication already registered for this project | ### Authentication errors | HTTP Status | Error Code | Message | |-------------|------------|---------| | 401 | `A_MISSING` | Missing API key | | 401 | `A_MISSING` | Missing API secret | | 401 | `A_MISSING` | Missing token | | 401 | `A_MISSING` | Missing auth provider | | 401 | `A_INVALID` | Invalid API key or API secret | | 401 | `A_INVALID` | Invalid token | | 401 | `A_INVALID` | Invalid auth provider | ### General errors | HTTP Status | Error Code | Message | |-------------|------------|---------| | 500 | `INTERNAL` | Internal error | | 500 | `MISSING_NOTIFICATION_SERV` | Missing notification service | | 400 | `BAD_REQUEST` | Various messages | ## Related * See how [users and projects](/introduction/users) authenticate against Shield. * Automatic recovery can require [OTP](/components/cold_storage/otp); compare options in [Recovery methods](/security/recovery-methods). # OTP for automatic recovery Shield supports One-Time Password (OTP) verification to add an additional layer of security when creating encrypted sessions. :::info OTP is available only for shares with automatic recovery method. ::: ## Enable OTP OTP is a project-level feature that must be enabled before it can be used. Once enabled, **OTP cannot be disabled** for a project. To enable OTP for a project, use the following endpoint: **Endpoint:** `POST /project/enable-2fa` **Headers:** * `X-API-Key`: Project's API key * `X-API-Secret`: Project's API secret **Response:** * `200 OK`: OTP enabled successfully * `409 Conflict`: OTP already enabled for this project ## How OTP works When OTP is enabled for a project, users must go through an OTP verification flow when creating an encrypted session. Here's the typical flow: ### 1. Request OTP Before creating an encrypted session, users must request an OTP code. **Endpoint:** `POST /project/otp` **Headers:** * `X-API-Key`: Project's API key * `X-API-Secret`: Project's API secret **Request Body:** ```json { "user_id": "user_external_id", "email": "user@example.com", "dangerously_skip_verification": false } ``` **OR** ```json { "user_id": "user_external_id", "phone": "+1234567890", "dangerously_skip_verification": false } ``` **Parameters:** * `user_id` (required): The external user ID * `email` (optional): User's email address to receive OTP via email * `phone` (optional): User's phone number to receive OTP via SMS * `dangerously_skip_verification` (optional, default: false): If set to `true`, skips OTP verification **Note:** You must provide either `email` or `phone`, but not both. **The `dangerously_skip_verification` Flag:** This flag can be used to simplify onboarding for new users. For example: * When **creating a new wallet**: Set this flag to `true` to skip OTP verification and streamline the signup process * When **recovering an existing wallet**: Set this flag to `false` to require OTP verification for additional security When this flag is set to `true`, an OTP is generated but not sent to the user, and the OTP verification step can be skipped when creating an encryption session. **OTP Delivery Methods:** Shield supports two delivery methods for OTP codes: 1. **Email OTP**: When an email address is provided, the OTP is sent to the user's email 2. **SMS OTP**: When a phone number is provided, the OTP is sent via SMS to the user's phone **Response:** * `200 OK`: OTP generated and sent successfully ### 2. Create encryption session with OTP After receiving the OTP, users create an encrypted session by providing the OTP code. **Endpoint:** `POST /project/encryption-session` **Headers:** * `X-API-Key`: Project's API key * `X-API-Secret`: Project's API secret **Request Body:** ```json { "encryption_part": "encryption_part_value", "user_id": "user_external_id", "otp_code": "123456789" } ``` **Parameters:** * `encryption_part` (required): The encryption part for the project * `user_id` (required): The external user ID * `otp_code` (optional): The OTP code received via email or SMS. Required if `dangerously_skip_verification` was `false` **Response:** ```json { "session_id": "generated_session_id" } ``` The `session_id` can then be used with the `X-Encryption-Session` header when registering, updating, or retrieving shares. ## OTP security features * **OTP Verification**: When OTP is enabled and `dangerously_skip_verification` is `false`, users must provide a valid OTP code to create an encrypted session * **Contact Verification**: Shield verifies and stores hashed contact information (email or phone) to ensure consistency across requests * **Rate Limiting**: Project-level rate limits prevent abuse of OTP generation * **Session Expiry**: Encryption sessions are time-limited for security ## Example workflows ### New user signup (skip verification) ``` 1. POST /project/otp with dangerously_skip_verification: true 2. POST /project/encryption-session (no OTP code needed) 3. Use session_id to register shares ``` ### Existing user recovery (with verification) ``` 1. POST /project/otp with dangerously_skip_verification: false 2. User receives OTP via email or SMS 3. POST /project/encryption-session with OTP code 4. Use session_id to retrieve shares ``` ## OTP errors | HTTP Status | Error Code | Message | |-------------|------------|---------| | 429 | `OTP_RATE_LIMIT` | Rate limit exceeded to generate OTP | | 422 | `OTP_EXPIRED` | OTP is expired | | 400 | `OTP_INVALIDATED` | OTP invalidated after max failed attempts | | 400 | `OTP_INVALID` | Received otp is invalid | | 400 | `OTP_REQUESTED_BUT_NOT_SENT` | OTP was requested but not sent | | 428 | `OTP_MISSING` | OTP is required for this request | | 404 | `OTP_RECORD_NOT_FOUND` | OTP record not found for user | | 400 | `OTP_USER_INFO_MISSING` | Missing user information like email or phone number | | 400 | `OTP_NOT_SUPPORTED` | Project doesn't support OTP | | 409 | `OTP_ALREADY_ENABLED` | Project already has OTP enabled | OTP verification follows the standard one-time password schemes, [HOTP (RFC 4226)](https://datatracker.ietf.org/doc/html/rfc4226) and [TOTP (RFC 6238)](https://datatracker.ietf.org/doc/html/rfc6238). ## Related * OTP is part of [automatic recovery](/security/recovery-methods). * It runs against [cold storage (Shield)](/components/shield) when [creating a key](/actions/signup). # Postman collection A Postman collection is available for testing the components. It runs against the service created with the `make clean build run` command. Download the [Postman collection](https://github.com/openfort-xyz/opensigner/blob/main/docs/public/postman/keys.json). ## Related * Start with [Getting started](/introduction/getting-started) and the [authentication service API](/apis/auth_service). Import the collection with [Postman](https://www.postman.com/). See the [cold storage API](/apis/cold_storage) and [hot storage API](/apis/hot_storage) references, generated from [OpenAPI](https://www.openapis.org/) definitions. # OpenSigner Auth Service API Version: `1.0.0` The authentication service is built on [Better Auth](https://www.better-auth.com/) and provides user registration, session management, JWT issuance, and origin validation. All `/api/auth/*` endpoints are handled by Better Auth. The service also exposes a JWKS endpoint, an origin-validation endpoint consumed by the iframe nginx proxy, and a health check. ## Reading the constraints in this document String bounds marked as *documented bounds* describe the range this API is designed to accept; they are not all enforced by server-side validation today. Treat them as the contract a client should keep to, not as a promise that a longer value is rejected. ## Servers - `http://localhost:7052`: Local development (docker-compose) - `{scheme}://{host}`: Self-hosted deployment. OpenSigner has no canonical hosted instance; substitute the host you deploy the auth service to. ## Endpoints ### Authentication Sign up, sign in, and sign out with email and password. - [`POST /api/auth/sign-up/email`](/apis/auth_service/authentication#signupemail): Sign up with email and password - [`POST /api/auth/sign-in/email`](/apis/auth_service/authentication#signinemail): Sign in with email and password - [`POST /api/auth/sign-in/username`](/apis/auth_service/authentication#signinusername): Sign in with username and password - [`POST /api/auth/sign-out`](/apis/auth_service/authentication#signout): Sign out and invalidate session ### Session Session management and JWT token retrieval. - [`GET /api/auth/get-session`](/apis/auth_service/session#getsession): Get the current session - [`GET /api/auth/token`](/apis/auth_service/session#gettoken): Get a JWT access token ### JWKS JSON Web Key Set endpoints for token verification. - [`GET /api/auth/jwks`](/apis/auth_service/jwks#getjwks): JSON Web Key Set - [`GET /.well-known/jwks.json`](/apis/auth_service/jwks#getwellknownjwks): JWKS (well-known alias) ### Origin Validation Origin validation for the iframe nginx proxy. - [`GET /v1/projects/validate-origin`](/apis/auth_service/origin-validation#validateorigin): Validate a request origin ### Health Service health check. - [`GET /health`](/apis/auth_service/health#gethealth): Health check # Openfort Hot Storage API Version: `1.0.0` Stores the "hot" key share for a signer's devices and accounts. ## Authentication Every endpoint in this document requires a bearer token. The whole API mux is wrapped in `authMiddleware`, which rejects any request it cannot resolve to a user with 401 before the handler runs. There are no anonymous endpoints here; `/health` is served outside this API surface and is not documented below. The token is validated according to the `X-Auth-Provider` header. When that header is absent the default provider is used and the token is verified against the auth service's JWKS; other providers verify third-party tokens. ## Servers - `http://localhost:7054`: Local development (docker-compose) - `{scheme}://{host}`: Self-hosted deployment. OpenSigner has no canonical hosted instance; substitute the host you deploy hot storage to. ## Endpoints ### Devices Register, recover, and inspect devices holding a key share. - [`POST /v2/devices/recover`](/apis/hot_storage/devices#recoverdevice): Recover an embedded device - [`POST /v2/devices/register`](/apis/hot_storage/devices#registerdevice): Register an embedded device - [`POST /v2/devices/create`](/apis/hot_storage/devices#createdevice): Create a new embedded device - [`POST /v1/devices/init`](/apis/hot_storage/devices#initdevice): Initialize device registration or recovery - [`POST /v1/devices/register`](/apis/hot_storage/devices#registerdevicev1): Register a device (v1) - [`GET /v1/devices/{deviceId}`](/apis/hot_storage/devices#getdevice): Get a device by ID - [`GET /v1/devices`](/apis/hot_storage/devices#listdevices): List devices for the authenticated user - [`POST /v1/devices`](/apis/hot_storage/devices#createdevicev1): Create a device for an existing account ### Accounts Accounts linking a user to a signer, plus share import and migration metadata. - [`GET /v2/accounts`](/apis/hot_storage/accounts#getaccountsv2): List accounts of a user. - [`GET /v2/accounts/signer`](/apis/hot_storage/accounts#getsignerv2): Get the signer for an account - [`POST /v2/accounts/import-share`](/apis/hot_storage/accounts#importshare): Import a key share from an external source - [`GET /v2/accounts/migrated-data`](/apis/hot_storage/accounts#getmigratedaccountdata): Get migration metadata for an account # Openfort Cold Storage API Version: `0.0.1` Shield by Openfort is a secure service dedicated to the protection of sensitive data. It ensures the confidentiality and security of data management by offering a robust framework for storing secrets and encryption parameters. ## Servers - `http://localhost:7053`: Local development (docker-compose) - `{scheme}://{host}`: Self-hosted deployment. OpenSigner has no canonical hosted instance; substitute the host you deploy shield to. ## Endpoints ### Shares Operations related to managing user shares - [`GET /shares`](/apis/cold_storage/shares#get-shares): Get Share - [`POST /shares`](/apis/cold_storage/shares#post-shares): Register Share - [`PUT /shares`](/apis/cold_storage/shares#put-shares): Update Share - [`DELETE /shares`](/apis/cold_storage/shares#delete-shares): Delete Share ### Projects Operations related to project management - [`POST /register`](/apis/cold_storage/projects#post-register): Create Project - [`GET /project`](/apis/cold_storage/projects#get-project): Get Project - [`POST /project/otp`](/apis/cold_storage/projects#post-projectotp): Request OTP ### Providers Operations related to authentication providers - [`GET /project/providers`](/apis/cold_storage/providers#get-projectproviders): Get Providers - [`POST /project/providers`](/apis/cold_storage/providers#post-projectproviders): Add Providers - [`GET /project/providers/{provider}`](/apis/cold_storage/providers#get-projectprovidersprovider): Get Provider - [`PUT /project/providers/{provider}`](/apis/cold_storage/providers#put-projectprovidersprovider): Update Provider - [`DELETE /project/providers/{provider}`](/apis/cold_storage/providers#delete-projectprovidersprovider): Delete Provider ### Encryption Operations related to encryption and security - [`POST /project/encrypt`](/apis/cold_storage/encryption#post-projectencrypt): Encrypt Project Shares - [`POST /project/encryption-session`](/apis/cold_storage/encryption#post-projectencryption-session): Register Encryption Session - [`POST /project/encryption-key`](/apis/cold_storage/encryption#post-projectencryption-key): Register Encryption Key # Authentication Sign up, sign in, and sign out with email and password. ## Sign up with email and password `POST /api/auth/sign-up/email` Register a new user with email and password. With the username plugin enabled, an optional `username` field is accepted (max 30 characters, `admin` is blocked). ### Request body (required) (`application/json`) - `email` `string ` _(required)_: Email address. The 254-character bound is the RFC 5321 maximum. - `password` `string ` _(required)_: Account password. The bound below is a documented request bound rather than a server-enforced limit. - `name` `string` _(required)_: Human-readable account name. Documented request bound, not enforced by server-side validation. - `username` `string`: Username. The 30-character maximum is enforced by the username plugin (`maxUsernameLength: 30`). The value `admin` is rejected. ### Responses #### `200`: User created successfully Body (`application/json`): - `user` `object` - `id` `string`: Opaque record identifier issued by Better Auth. - `email` `string `: Email address. The 254-character bound is the RFC 5321 maximum. - `name` `string`: Human-readable account name. Documented request bound, not enforced by server-side validation. - `username` `string`: Username. The 30-character maximum is enforced by the username plugin (`maxUsernameLength: 30`). The value `admin` is rejected. - `emailVerified` `boolean` - `createdAt` `string `: ISO 8601 timestamp. - `updatedAt` `string `: ISO 8601 timestamp. - `session` `object` - `id` `string`: Opaque record identifier issued by Better Auth. - `userId` `string`: Opaque record identifier issued by Better Auth. - `expiresAt` `string `: ISO 8601 timestamp. - `token` `string`: Opaque session token issued by Better Auth. - `token` `string`: Opaque session token issued by Better Auth. #### `400`: The request was malformed or failed validation. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `401`: Authentication is missing or invalid. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `403`: The caller is authenticated but not permitted to perform this action. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `404`: No resource exists at this path. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `429`: Too many requests. Sign-up is limited to 30 requests per hour per client address. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `500`: The service failed to process the request. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `default`: Unexpected error. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. ### Example request ```bash curl http://localhost:7052/api/auth/sign-up/email \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "email": "user@example.com", "password": "s3cur3P@ss", "name": "Jane Doe", "username": "janedoe" }' ``` ```ts fetch('http://localhost:7052/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'user@example.com', password: 's3cur3P@ss', name: 'Jane Doe', username: 'janedoe' }) }) ``` ## Sign in with email and password `POST /api/auth/sign-in/email` ### Request body (required) (`application/json`) - `email` `string ` _(required)_: Email address. The 254-character bound is the RFC 5321 maximum. - `password` `string ` _(required)_: Account password. The bound below is a documented request bound rather than a server-enforced limit. ### Responses #### `200`: Sign-in successful Body (`application/json`): - `user` `object` - `id` `string`: Opaque record identifier issued by Better Auth. - `email` `string `: Email address. The 254-character bound is the RFC 5321 maximum. - `name` `string`: Human-readable account name. Documented request bound, not enforced by server-side validation. - `username` `string`: Username. The 30-character maximum is enforced by the username plugin (`maxUsernameLength: 30`). The value `admin` is rejected. - `emailVerified` `boolean` - `createdAt` `string `: ISO 8601 timestamp. - `updatedAt` `string `: ISO 8601 timestamp. - `session` `object` - `id` `string`: Opaque record identifier issued by Better Auth. - `userId` `string`: Opaque record identifier issued by Better Auth. - `expiresAt` `string `: ISO 8601 timestamp. - `token` `string`: Opaque session token issued by Better Auth. - `token` `string`: Opaque session token issued by Better Auth. #### `400`: The request was malformed or failed validation. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `401`: Authentication is missing or invalid. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `403`: The caller is authenticated but not permitted to perform this action. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `404`: No resource exists at this path. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `429`: Too many requests. Sign-in is limited to 5 requests per 60 seconds per client address. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `500`: The service failed to process the request. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `default`: Unexpected error. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. ### Example request ```bash curl http://localhost:7052/api/auth/sign-in/email \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "email": "user@example.com", "password": "s3cur3P@ss" }' ``` ```ts fetch('http://localhost:7052/api/auth/sign-in/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'user@example.com', password: 's3cur3P@ss' }) }) ``` ## Sign in with username and password `POST /api/auth/sign-in/username` Provided by the username plugin. Behaves like sign-in with email, but identifies the user by username. ### Request body (required) (`application/json`) - `username` `string` _(required)_: Username. The 30-character maximum is enforced by the username plugin (`maxUsernameLength: 30`). The value `admin` is rejected. - `password` `string ` _(required)_: Account password. The bound below is a documented request bound rather than a server-enforced limit. ### Responses #### `200`: Sign-in successful Body (`application/json`): - `user` `object` - `id` `string`: Opaque record identifier issued by Better Auth. - `email` `string `: Email address. The 254-character bound is the RFC 5321 maximum. - `name` `string`: Human-readable account name. Documented request bound, not enforced by server-side validation. - `username` `string`: Username. The 30-character maximum is enforced by the username plugin (`maxUsernameLength: 30`). The value `admin` is rejected. - `emailVerified` `boolean` - `createdAt` `string `: ISO 8601 timestamp. - `updatedAt` `string `: ISO 8601 timestamp. - `session` `object` - `id` `string`: Opaque record identifier issued by Better Auth. - `userId` `string`: Opaque record identifier issued by Better Auth. - `expiresAt` `string `: ISO 8601 timestamp. - `token` `string`: Opaque session token issued by Better Auth. - `token` `string`: Opaque session token issued by Better Auth. #### `400`: The request was malformed or failed validation. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `401`: Authentication is missing or invalid. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `403`: The caller is authenticated but not permitted to perform this action. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `404`: No resource exists at this path. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `429`: Too many requests. Sign-in is limited to 5 requests per 60 seconds per client address. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `500`: The service failed to process the request. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `default`: Unexpected error. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. ### Example request ```bash curl http://localhost:7052/api/auth/sign-in/username \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "username": "janedoe", "password": "s3cur3P@ss" }' ``` ```ts fetch('http://localhost:7052/api/auth/sign-in/username', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'janedoe', password: 's3cur3P@ss' }) }) ``` ## Sign out and invalidate session `POST /api/auth/sign-out` Invalidate the current session. Requires a session cookie or Bearer token. This operation takes no request body. ### Responses #### `200`: Signed out successfully Body (`application/json`): - `success` `boolean` #### `401`: Authentication is missing or invalid. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `403`: The caller is authenticated but not permitted to perform this action. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `404`: No resource exists at this path. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `429`: Too many requests. Better Auth applies a default budget of 100 requests per 60 seconds per client address across `/api/auth/*`. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `500`: The service failed to process the request. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `default`: Unexpected error. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. ### Example request ```bash curl http://localhost:7052/api/auth/sign-out \ --request POST ``` ```ts fetch('http://localhost:7052/api/auth/sign-out', { method: 'POST' }) ``` # Health Service health check. ## Health check `GET /health` ### Responses #### `200`: Service is healthy Body (`application/json`): - `status` `string` #### `500`: The service failed to process the request. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `default`: Unexpected error. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. ### Example request ```bash curl http://localhost:7052/health ``` ```ts fetch('http://localhost:7052/health') ``` # JWKS JSON Web Key Set endpoints for token verification. ## JSON Web Key Set `GET /api/auth/jwks` Return the public keys used to verify JWT tokens issued by the auth service. ### Responses #### `200`: JWKS response Body (`application/json`): - `keys` `object[]` - `kid` `string` - `kty` `string` - `alg` `string` - `use` `string` - `crv` `string` - `x` `string`: Base64url-encoded public key material. - `y` `string`: Base64url-encoded public key material (EC keys). - `n` `string`: Base64url-encoded modulus (RSA keys). - `e` `string`: Base64url-encoded exponent (RSA keys). #### `429`: Too many requests. Better Auth applies a default budget of 100 requests per 60 seconds per client address across `/api/auth/*`. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `500`: The service failed to process the request. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `default`: Unexpected error. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. ### Example request ```bash curl http://localhost:7052/api/auth/jwks ``` ```ts fetch('http://localhost:7052/api/auth/jwks') ``` ## JWKS (well-known alias) `GET /.well-known/jwks.json` Alias for `/api/auth/jwks`. Used by the hot storage service to validate JWT tokens. ### Responses #### `200`: JWKS response Body (`application/json`): - `keys` `object[]` - `kid` `string` - `kty` `string` - `alg` `string` - `use` `string` - `crv` `string` - `x` `string`: Base64url-encoded public key material. - `y` `string`: Base64url-encoded public key material (EC keys). - `n` `string`: Base64url-encoded modulus (RSA keys). - `e` `string`: Base64url-encoded exponent (RSA keys). #### `429`: Too many requests. Better Auth applies a default budget of 100 requests per 60 seconds per client address across `/api/auth/*`. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `500`: The service failed to process the request. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `default`: Unexpected error. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. ### Example request ```bash curl http://localhost:7052/.well-known/jwks.json ``` ```ts fetch('http://localhost:7052/.well-known/jwks.json') ``` # Origin Validation Origin validation for the iframe nginx proxy. ## Validate a request origin `GET /v1/projects/validate-origin` Used by the iframe nginx `auth_request` subrequest. Checks whether the `X-Request-Origin` header value is in the configured `ALLOWED_ORIGINS` list. On success the `X-Allowed-Origins` response header carries the allow-list for CSP `frame-ancestors`. A missing or empty `X-Request-Origin` is refused with 403. The endpoint does not substitute a default origin. Responses are sent with `res.sendStatus`, so the body is the plain-text status phrase rather than JSON. ### Header parameters - `X-Request-Origin` `string` _(required)_: The origin to validate. A missing or empty value is refused with 403. ### Responses #### `200`: Origin is allowed Headers: - `X-Allowed-Origins` `string`: Space-separated list of allowed origins #### `403`: Origin is not allowed, or the `X-Request-Origin` header was missing or empty. #### `500`: The service failed to process the request. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `default`: Unexpected error. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. ### Example request ```bash curl http://localhost:7052/v1/projects/validate-origin \ --header 'X-Request-Origin: http://localhost:7051' ``` ```ts fetch('http://localhost:7052/v1/projects/validate-origin', { headers: { 'X-Request-Origin': 'http://localhost:7051' } }) ``` # Session Session management and JWT token retrieval. ## Get the current session `GET /api/auth/get-session` Return the current user and session. Requires a session cookie or Bearer token. ### Responses #### `200`: Current session Body (`application/json`): - `user` `object` - `id` `string`: Opaque record identifier issued by Better Auth. - `email` `string `: Email address. The 254-character bound is the RFC 5321 maximum. - `name` `string`: Human-readable account name. Documented request bound, not enforced by server-side validation. - `username` `string`: Username. The 30-character maximum is enforced by the username plugin (`maxUsernameLength: 30`). The value `admin` is rejected. - `emailVerified` `boolean` - `createdAt` `string `: ISO 8601 timestamp. - `updatedAt` `string `: ISO 8601 timestamp. - `session` `object` - `id` `string`: Opaque record identifier issued by Better Auth. - `userId` `string`: Opaque record identifier issued by Better Auth. - `expiresAt` `string `: ISO 8601 timestamp. - `token` `string`: Opaque session token issued by Better Auth. - `token` `string`: Opaque session token issued by Better Auth. #### `401`: Authentication is missing or invalid. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `403`: The caller is authenticated but not permitted to perform this action. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `404`: No resource exists at this path. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `429`: Too many requests. Better Auth applies a default budget of 100 requests per 60 seconds per client address across `/api/auth/*`. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `500`: The service failed to process the request. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `default`: Unexpected error. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. ### Example request ```bash curl http://localhost:7052/api/auth/get-session ``` ```ts fetch('http://localhost:7052/api/auth/get-session') ``` ## Get a JWT access token `GET /api/auth/token` Exchange the current session for a short-lived JWT access token. Requires a session cookie or Bearer token. ### Responses #### `200`: JWT token Body (`application/json`): - `token` `string`: Signed JWT access token in compact serialization. #### `401`: Authentication is missing or invalid. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `403`: The caller is authenticated but not permitted to perform this action. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `404`: No resource exists at this path. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `429`: Too many requests. Better Auth applies a default budget of 100 requests per 60 seconds per client address across `/api/auth/*`. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `500`: The service failed to process the request. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. #### `default`: Unexpected error. Body (`application/json`): - `message` `string`: Human-readable description of the failure. - `code` `string`: Stable machine-readable error identifier. ### Example request ```bash curl http://localhost:7052/api/auth/token ``` ```ts fetch('http://localhost:7052/api/auth/token') ``` # Encryption Operations related to encryption and security ## Encrypt Project Shares `POST /project/encrypt` Encrypts all project shares using the provided encryption part ### Request body (required) (`application/json`) - `encryption_part` `string` _(required)_: Encryption part for encrypting shares ### Responses #### `200`: Shares encrypted successfully #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/project/encrypt \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "encryption_part": "encryption_part_value" }' ``` ```ts fetch('http://localhost:7053/project/encrypt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ encryption_part: 'encryption_part_value' }) }) ``` ## Register Encryption Session `POST /project/encryption-session` Creates a one-time encryption session for secure operations ### Request body (required) (`application/json`) - `encryption_part` `string` _(required)_: Encryption part for the session ### Responses #### `200`: Encryption session registered successfully Body (`application/json`): - `session_id` `string`: Generated session identifier #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/project/encryption-session \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "encryption_part": "encryption_part_value" }' ``` ```ts fetch('http://localhost:7053/project/encryption-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ encryption_part: 'encryption_part_value' }) }) ``` ## Register Encryption Key `POST /project/encryption-key` Generates and registers a new encryption key for the project ### Responses #### `200`: Encryption key registered successfully Body (`application/json`): - `encryption_part` `string`: Generated encryption part #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/project/encryption-key \ --request POST ``` ```ts fetch('http://localhost:7053/project/encryption-key', { method: 'POST' }) ``` # Projects Operations related to project management ## Create Project `POST /register` Creates a new project with API keys and optional encryption key ### Request body (required) (`application/json`) - `name` `string` _(required)_: Name of the project - `generate_encryption_key` `boolean`: Whether to generate an encryption key during creation ### Responses #### `201`: Project created successfully Body (`application/json`): - `id` `string`: Unique project identifier - `name` `string`: Project name - `api_key` `string`: Generated API key for the project - `api_secret` `string`: Generated API secret for the project - `encryption_part` `string`: Generated encryption part (if requested) #### `400`: Bad Request - Invalid request body or parameters Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/register \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "name": "My Project", "generate_encryption_key": true }' ``` ```ts fetch('http://localhost:7053/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'My Project', generate_encryption_key: true }) }) ``` ## Get Project `GET /project` Retrieves project details ### Responses #### `200`: Project details retrieved successfully Body (`application/json`): - `id` `string`: Project identifier - `name` `string`: Project name #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `404`: Not Found - The requested resource was not found Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/project ``` ```ts fetch('http://localhost:7053/project') ``` ## Request OTP `POST /project/otp` Generate and send a one-time password (OTP) to a user via email or phone ### Request body (required) (`application/json`) - `user_id` `string` _(required)_: The unique identifier of the user - `dangerously_skip_verification` `boolean`: Skip verification checks (use with caution) - `email` `string `: Email address to send OTP (mutually exclusive with phone) - `phone` `string`: Phone number to send OTP (mutually exclusive with email) ### Responses #### `200`: OTP generated and sent successfully #### `400`: Bad Request - Invalid parameters or request body Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `404`: Project not found or OTP record not found Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `429`: OTP rate limit exceeded Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/project/otp \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "user_id": "string", "dangerously_skip_verification": false, "email": "string", "phone": "string" }' ``` ```ts fetch('http://localhost:7053/project/otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: 'string', dangerously_skip_verification: false, email: 'string', phone: 'string' }) }) ``` # Providers Operations related to authentication providers ## Get Providers `GET /project/providers` Retrieves all providers associated with the project ### Responses #### `200`: Providers retrieved successfully Body (`application/json`): - `providers` `object[]` - `provider_id` `string` - `type` `string` #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/project/providers ``` ```ts fetch('http://localhost:7053/project/providers') ``` ## Add Providers `POST /project/providers` Adds authentication providers to the project ### Request body (required) (`application/json`) - `providers` `object` - `openfort` `object` - `publishable_key` `string`: Openfort publishable key - `custom` `object` - `jwk` `string`: JSON Web Key for custom provider - `pem` `string`: PEM certificate for custom provider - `key_type` `string`: Key type for custom provider ### Responses #### `200`: Providers added successfully Body (`application/json`): - `providers` `object[]` - `provider_id` `string`: Unique provider identifier - `type` `string`: Provider type #### `400`: Bad Request - Invalid request body or parameters Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/project/providers \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "providers": { "openfort": { "publishable_key": "openfort_publishable_key" }, "custom": { "jwk": "custom_jwk", "pem": "custom_pem", "key_type": "rsa" } } }' ``` ```ts fetch('http://localhost:7053/project/providers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ providers: { openfort: { publishable_key: 'openfort_publishable_key' }, custom: { jwk: 'custom_jwk', pem: 'custom_pem', key_type: 'rsa' } } }) }) ``` ## Get Provider `GET /project/providers/{provider}` Retrieves details of a specific provider ### Path parameters - `provider` `string` _(required)_: Provider ID ### Responses #### `200`: Provider details retrieved successfully Body (`application/json`): - `provider_id` `string` - `type` `string` - `jwk` `string` - `pem` `string` - `key_type` `string` - `publishable_key` `string` #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `404`: Not Found - The requested resource was not found Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/project/providers/string ``` ```ts fetch('http://localhost:7053/project/providers/string') ``` ## Update Provider `PUT /project/providers/{provider}` Updates a specific provider's configuration ### Path parameters - `provider` `string` _(required)_: Provider ID ### Request body (required) (`application/json`) - `publishable_key` `string`: Updated publishable key for Openfort provider - `jwk` `string`: Updated JWK for custom provider - `pem` `string`: Updated PEM certificate for custom provider - `key_type` `string`: Updated key type for custom provider ### Responses #### `200`: Provider updated successfully #### `400`: Bad Request - Invalid request body or parameters Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `404`: Not Found - The requested resource was not found Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/project/providers/string \ --request PUT \ --header 'Content-Type: application/json' \ --data '{ "publishable_key": "new_publishable_key", "jwk": "new_jwk", "pem": "new_pem", "key_type": "ecdsa" }' ``` ```ts fetch('http://localhost:7053/project/providers/string', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ publishable_key: 'new_publishable_key', jwk: 'new_jwk', pem: 'new_pem', key_type: 'ecdsa' }) }) ``` ## Delete Provider `DELETE /project/providers/{provider}` Deletes a specific provider from the project ### Path parameters - `provider` `string` _(required)_: Provider ID ### Responses #### `200`: Provider deleted successfully #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `404`: Not Found - The requested resource was not found Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/project/providers/string \ --request DELETE ``` ```ts fetch('http://localhost:7053/project/providers/string', { method: 'DELETE' }) ``` # Shares Operations related to managing user shares ## Get Share `GET /shares` Retrieves share details for the authenticated user ### Header parameters - `X-Auth-Provider` `string` _(required)_: Authentication provider type - `X-Openfort-Provider` `string`: Openfort provider details (required when using third-party with Openfort) - `X-Openfort-Token-Type` `string`: Openfort token type (required when using third-party with Openfort) - `X-Encryption-Part` `string`: Encryption part for decrypting shares - `X-Encryption-Session` `string`: Encryption session ID for secure operations ### Responses #### `200`: Share details retrieved successfully Body (`application/json`): - `secret` `string` - `entropy` `string` - `salt` `string` - `iterations` `integer` - `length` `integer` - `digest` `string` - `encryption_part` `string` - `encryption_session` `string` #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `404`: Not Found - The requested resource was not found Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/shares \ --header 'X-Auth-Provider: openfort' ``` ```ts fetch('http://localhost:7053/shares', { headers: { 'X-Auth-Provider': 'openfort' } }) ``` ## Register Share `POST /shares` Registers a new share for the authenticated user ### Header parameters - `X-Auth-Provider` `string` _(required)_: Authentication provider type - `X-Openfort-Provider` `string`: Openfort provider details (required when using third-party with Openfort) - `X-Openfort-Token-Type` `string`: Openfort token type (required when using third-party with Openfort) - `X-Encryption-Part` `string`: Encryption part for decrypting shares - `X-Encryption-Session` `string`: Encryption session ID for secure operations - `X-User-ID` `string`: User ID for admin operations (when using API Key/Secret auth) ### Request body (required) (`application/json`) - `secret` `string` _(required)_: The secret value to be stored - `entropy` `string` _(required)_: Encryption type for the share - `salt` `string`: Salt used for encryption (required for user entropy) - `iterations` `integer`: Number of iterations for encryption algorithm - `length` `integer`: Length of the encrypted data - `digest` `string`: Hashing algorithm used - `encryption_part` `string`: Encryption part for project entropy - `encryption_session` `string`: Session ID for encryption operations ### Responses #### `201`: Share registered successfully #### `400`: Bad Request - Invalid request body or parameters Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/shares \ --request POST \ --header 'X-Auth-Provider: openfort' \ --header 'Content-Type: application/json' \ --data '{ "secret": "some_secret_value", "entropy": "user", "salt": "some_salt_value", "iterations": 1000, "length": 256, "digest": "sha256", "encryption_part": "part_value", "encryption_session": "session_value" }' ``` ```ts fetch('http://localhost:7053/shares', { method: 'POST', headers: { 'X-Auth-Provider': 'openfort', 'Content-Type': 'application/json' }, body: JSON.stringify({ secret: 'some_secret_value', entropy: 'user', salt: 'some_salt_value', iterations: 1000, length: 256, digest: 'sha256', encryption_part: 'part_value', encryption_session: 'session_value' }) }) ``` ## Update Share `PUT /shares` Updates an existing share for the authenticated user ### Header parameters - `X-Auth-Provider` `string` _(required)_: Authentication provider type - `X-Openfort-Provider` `string`: Openfort provider details (required when using third-party with Openfort) - `X-Openfort-Token-Type` `string`: Openfort token type (required when using third-party with Openfort) - `X-Encryption-Part` `string`: Encryption part for decrypting shares - `X-Encryption-Session` `string`: Encryption session ID for secure operations ### Request body (required) (`application/json`) - `secret` `string` _(required)_: The updated secret value - `entropy` `string` _(required)_: Encryption type for the share - `salt` `string`: Updated salt for encryption - `iterations` `integer`: Updated number of iterations - `length` `integer`: Updated length of encrypted data - `digest` `string`: Updated hashing algorithm - `encryption_part` `string`: Updated encryption part - `encryption_session` `string`: Updated session ID ### Responses #### `200`: Share updated successfully Body (`application/json`): - `secret` `string` - `entropy` `string` - `salt` `string` - `iterations` `integer` - `length` `integer` - `digest` `string` - `encryption_part` `string` - `encryption_session` `string` #### `400`: Bad Request - Invalid request body or parameters Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/shares \ --request PUT \ --header 'X-Auth-Provider: openfort' \ --header 'Content-Type: application/json' \ --data '{ "secret": "updated_secret_value", "entropy": "project", "salt": "updated_salt_value", "iterations": 2000, "length": 512, "digest": "sha512", "encryption_part": "updated_part_value", "encryption_session": "updated_session_value" }' ``` ```ts fetch('http://localhost:7053/shares', { method: 'PUT', headers: { 'X-Auth-Provider': 'openfort', 'Content-Type': 'application/json' }, body: JSON.stringify({ secret: 'updated_secret_value', entropy: 'project', salt: 'updated_salt_value', iterations: 2000, length: 512, digest: 'sha512', encryption_part: 'updated_part_value', encryption_session: 'updated_session_value' }) }) ``` ## Delete Share `DELETE /shares` Deletes the share for the authenticated user ### Header parameters - `X-Auth-Provider` `string` _(required)_: Authentication provider type - `X-Openfort-Provider` `string`: Openfort provider details (required when using third-party with Openfort) - `X-Openfort-Token-Type` `string`: Openfort token type (required when using third-party with Openfort) - `X-Encryption-Part` `string`: Encryption part for decrypting shares - `X-Encryption-Session` `string`: Encryption session ID for secure operations ### Responses #### `204`: Share deleted successfully #### `401`: Unauthorized - Invalid or missing authentication credentials Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `404`: Not Found - The requested resource was not found Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details #### `500`: Internal Server Error - An unexpected server error occurred Body (`application/json`): - `error` `string`: Error message - `code` `string`: Error code - `details` `object`: Additional error details ### Example request ```bash curl http://localhost:7053/shares \ --request DELETE \ --header 'X-Auth-Provider: openfort' ``` ```ts fetch('http://localhost:7053/shares', { method: 'DELETE', headers: { 'X-Auth-Provider': 'openfort' } }) ``` # Accounts Accounts linking a user to a signer, plus share import and migration metadata. ## List accounts of a user. `GET /v2/accounts` Returns a list of accounts for the given user. This object represents a user's account, which is a blockchain smart account or EOA that can be used to interact with the blockchain. The accounts are returned sorted by creation date, with the most recently created accounts appearing first. Returns the latest 10 transaction intents for each account. By default, a maximum of 10 accounts are shown per page. ### Query parameters - `limit` `integer `: Specifies the maximum number of records to return. - `skip` `integer `: Specifies the offset for the first records to return. - `order` `string`: Specifies the order in which to sort the results. - `chainId` `integer `: The chain ID. Must be a [supported chain](/development/chains). - `user` `string`: Specifies the unique user ID (starts with pla_) - `chainType` `string`: The chain type. Must be either "EVM" or "SVM". - `accountType` `string`: Specifies the type of account. Must be either "Smart Account" or "Externally Owned Account". - `address` `string`: Specifies the account address ### Header parameters - `X-Auth-Provider` `string`: Selects how the bearer token is validated. Omit it to use the default provider, which verifies the token against the auth service's JWKS. ### Responses #### `200`: Successful response. Body (`application/json`): - `object` `string` _(required)_: Response type indicator - `url` `string` _(required)_: URL of the list endpoint - `data` `object[]` _(required)_: Array of account records - `id` `string` _(required)_: Unique account identifier - `user` `string` _(required)_: User ID (starts with pla_) - `accountType` `string` _(required)_: Type of account (Smart Account or Externally Owned Account) - `address` `string` _(required)_: Account address - `ownerAddress` `string`: Owner address of the account - `chainType` `string` _(required)_: Chain type (EVM or SVM) - `chainId` `number `: Chain ID - `createdAt` `number ` _(required)_: Account creation timestamp - `updatedAt` `number ` _(required)_: Account last update timestamp - `smartAccount` `object`: Smart account specific data (only for Smart Accounts) - `implementationType` `string` _(required)_: Smart account implementation type - `factoryAddress` `string` _(required)_: Factory contract address - `implementationAddress` `string` _(required)_: Implementation contract address - `salt` `string` _(required)_: Salt used for deployment - `deployedTx` `string`: Deployment transaction hash - `deployedAt` `number `: Deployment timestamp - `active` `boolean` _(required)_: Whether the smart account is active - `recoveryMethod` `string`: Recovery method type - `recoveryMethodDetails` `object`: Details about the recovery method - `passkeyId` `string`: Passkey identifier - `passkeyEnv` `object`: Passkey environment information - `name` `string`: Environment name - `os` `string`: Operating system - `osVersion` `string`: Operating system version - `device` `string`: Device type - `start` `integer ` _(required)_: Starting index of the results - `end` `integer ` _(required)_: Ending index of the results - `total` `integer ` _(required)_: Total number of records available #### `401`: Error response. #### `429`: Too Many Requests - per-user rate limit exceeded. Retry after the interval in the Retry-After header. ### Example request ```bash curl 'http://localhost:7054/v2/accounts?limit=0&skip=0&order=asc&chainId=0&user=string&chainType=string&accountType=string&address=string' ``` ```ts fetch('http://localhost:7054/v2/accounts?limit=0&skip=0&order=asc&chainId=0&user=string&chainType=string&accountType=string&address=string') ``` ## Get the signer for an account `GET /v2/accounts/signer` Returns the signer ID associated with an account at the given address for the authenticated user. ### Query parameters - `address` `string` _(required)_: The blockchain address of the account. ### Header parameters - `X-Auth-Provider` `string`: Selects how the bearer token is validated. Omit it to use the default provider, which verifies the token against the auth service's JWKS. ### Responses #### `200`: Successful response. Body (`application/json`): - `id` `string` _(required)_: Signer identifier #### `400`: Account not found or missing address parameter. #### `401`: Error response - Unauthorized #### `429`: Too Many Requests - per-user rate limit exceeded. Retry after the interval in the Retry-After header. ### Example request ```bash curl 'http://localhost:7054/v2/accounts/signer?address=string' ``` ```ts fetch('http://localhost:7054/v2/accounts/signer?address=string') ``` ## Import a key share from an external source `POST /v2/accounts/import-share` Imports a key share during migration from another system. Creates a new account, signer, and device, and records migration metadata. Returns 409 Conflict if an account already exists at the given address. ### Header parameters - `X-Auth-Provider` `string`: Selects how the bearer token is validated. Omit it to use the default provider, which verifies the token against the auth service's JWKS. ### Request body (required) (`application/json`) - `id` `string`: Account ID to assign (generated if omitted) - `wallet` `string`: Wallet identifier from the source system - `accountType` `string`: Type of account (Smart Account or Externally Owned Account) - `address` `string` _(required)_: Blockchain address of the account - `ownerAddress` `string`: Owner EOA address (required for smart accounts) - `chainType` `string`: Chain type (EVM or SVM) - `chainId` `integer`: Chain ID - `smartAccount` `object`: Smart account data (only for Smart Accounts) - `implementationType` `string` - `factoryAddress` `string` - `implementationAddress` `string` - `salt` `string` - `share` `string` _(required)_: The key share data to import - `signerId` `string`: Signer ID to assign (generated if omitted, sig_ prefix stripped) - `userId` `string` _(required)_: User ID from the source system (stored as migration metadata) ### Responses #### `201`: Share imported successfully. Body (`application/json`): - `id` `string` _(required)_: Account ID - `wallet` `string` _(required)_: Wallet identifier from the source system - `address` `string` _(required)_: Account address - `signerId` `string` _(required)_: Signer identifier #### `400`: Missing required fields. #### `401`: Error response - Unauthorized #### `409`: An account already exists at the given address. #### `415`: Unsupported Media Type - a request with a body must use Content-Type application/json. #### `429`: Too Many Requests - per-user rate limit exceeded. Retry after the interval in the Retry-After header. ### Example request ```bash curl http://localhost:7054/v2/accounts/import-share \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "id": "acc_6f6c9067-89fa-4fc8-ac72-c242a268c584", "wallet": "wal_a1b2c3d4-5678-90ab-cdef-1234567890ab", "accountType": "Smart Account", "address": "0xf7b4c54cca21cccf42796502bf94e2838fbd44c4", "ownerAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", "chainType": "EVM", "chainId": 80002, "smartAccount": { "implementationType": "string", "factoryAddress": "string", "implementationAddress": "string", "salt": "string" }, "share": "7d526b7e99fbf52850a183...", "signerId": "sig_a1b2c3d4-5678-90ab-cdef-1234567890ab", "userId": "pla_6f6c9067-89fa-4fc8-ac72-c242a268c584" }' ``` ```ts fetch('http://localhost:7054/v2/accounts/import-share', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'acc_6f6c9067-89fa-4fc8-ac72-c242a268c584', wallet: 'wal_a1b2c3d4-5678-90ab-cdef-1234567890ab', accountType: 'Smart Account', address: '0xf7b4c54cca21cccf42796502bf94e2838fbd44c4', ownerAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', chainType: 'EVM', chainId: 80002, smartAccount: { implementationType: 'string', factoryAddress: 'string', implementationAddress: 'string', salt: 'string' }, share: '7d526b7e99fbf52850a183...', signerId: 'sig_a1b2c3d4-5678-90ab-cdef-1234567890ab', userId: 'pla_6f6c9067-89fa-4fc8-ac72-c242a268c584' }) }) ``` ## Get migration metadata for an account `GET /v2/accounts/migrated-data` Returns migration metadata for an account that was imported from an external system. The authenticated user must own the account. ### Query parameters - `accountId` `string` _(required)_: The account ID to look up migration data for. ### Header parameters - `X-Auth-Provider` `string`: Selects how the bearer token is validated. Omit it to use the default provider, which verifies the token against the auth service's JWKS. ### Responses #### `200`: Successful response. Body (`application/json`): - `id` `string` _(required)_: Account ID - `wallet` `string` _(required)_: Wallet identifier from the source system - `former_user` `string` _(required)_: User ID from the source system #### `400`: Missing accountId parameter. #### `401`: Error response - Unauthorized #### `404`: Account or migration data not found. #### `429`: Too Many Requests - per-user rate limit exceeded. Retry after the interval in the Retry-After header. ### Example request ```bash curl 'http://localhost:7054/v2/accounts/migrated-data?accountId=string' ``` ```ts fetch('http://localhost:7054/v2/accounts/migrated-data?accountId=string') ``` # Devices Register, recover, and inspect devices holding a key share. ## Recover an embedded device `POST /v2/devices/recover` Recovers an embedded device for an existing account. This endpoint retrieves the device information including the share and signer details for a previously registered account. ### Header parameters - `X-Auth-Provider` `string`: Selects how the bearer token is validated. Omit it to use the default provider, which verifies the token against the auth service's JWKS. ### Request body (required) (`application/json`) - `account` `string` _(required)_: Specifies the unique account ID (starts with acc_) ### Responses #### `200`: Successful response. Body (`application/json`): - `id` `string` _(required)_: Unique device identifier - `account` `string` _(required)_: Account ID (starts with acc_) - `signerAddress` `string` _(required)_: Blockchain address of the signer - `signer` `string` _(required)_: Signer identifier - `share` `string` _(required)_: The encrypted share repository data used for key management - `isPrimary` `boolean` _(required)_: Indicates if this is the primary device for the account - `createdAt` `string ` _(required)_: Device creation timestamp in ISO 8601 format - `user` `string` _(required)_: User ID associated with the device (starts with pla_) #### `401`: Error response - Unauthorized #### `415`: Unsupported Media Type - a request with a body must use Content-Type application/json. #### `429`: Too Many Requests - per-user rate limit exceeded. Retry after the interval in the Retry-After header. ### Example request ```bash curl http://localhost:7054/v2/devices/recover \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "account": "acc_6f6c9067-89fa-4fc8-ac72-c242a268c584" }' ``` ```ts fetch('http://localhost:7054/v2/devices/recover', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account: 'acc_6f6c9067-89fa-4fc8-ac72-c242a268c584' }) }) ``` ## Register an embedded device `POST /v2/devices/register` Registers a new device for an existing account by associating a share with the account. This is used when adding a new device to an account that already exists. ### Header parameters - `X-Auth-Provider` `string`: Selects how the bearer token is validated. Omit it to use the default provider, which verifies the token against the auth service's JWKS. ### Request body (required) (`application/json`) - `account` `string` _(required)_: Specifies the unique account ID (starts with acc_) - `share` `string` _(required)_: The encrypted share repository data to register with the account ### Responses #### `200`: Successful response. Body (`application/json`): - `share` `string`: The encrypted share repository data used for key management - `accountType` `string` _(required)_: Type of account (Smart Account or Externally Owned Account) - `implementationType` `string`: Smart account implementation type (only for Smart Accounts) - `implementationAddress` `string`: Implementation contract address (only for Smart Accounts) - `factoryAddress` `string`: Factory contract address (only for Smart Accounts) - `salt` `string`: Salt used for smart account deployment (only for Smart Accounts) - `address` `string` _(required)_: Account blockchain address - `ownerAddress` `string` _(required)_: Owner address of the account - `chainType` `string` _(required)_: Chain type (EVM or SVM) - `chainId` `number`: Chain ID - `device` `string`: Device identifier - `account` `string` _(required)_: Account ID (starts with acc_) - `signer` `string` _(required)_: Signer identifier #### `401`: Error response - Unauthorized #### `415`: Unsupported Media Type - a request with a body must use Content-Type application/json. #### `429`: Too Many Requests - per-user rate limit exceeded. Retry after the interval in the Retry-After header. ### Example request ```bash curl http://localhost:7054/v2/devices/register \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "account": "acc_6f6c9067-89fa-4fc8-ac72-c242a268c584", "share": "7d526b7e99fbf52850a183..." }' ``` ```ts fetch('http://localhost:7054/v2/devices/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ account: 'acc_6f6c9067-89fa-4fc8-ac72-c242a268c584', share: '7d526b7e99fbf52850a183...' }) }) ``` ## Create a new embedded device `POST /v2/devices/create` Creates a new account and registers the first device for it. This endpoint handles the complete setup of a new account with an embedded signer. ### Header parameters - `X-Auth-Provider` `string`: Selects how the bearer token is validated. Omit it to use the default provider, which verifies the token against the auth service's JWKS. ### Request body (required) (`application/json`) - `accountType` `string` _(required)_: Type of account to create (Smart Account or Externally Owned Account) - `chainType` `string` _(required)_: Chain type (EVM or SVM) - `address` `string` _(required)_: Account blockchain address - `chainId` `number`: Chain ID (optional) - `privateKey` `string`: Private key for EOA accounts (optional, for Externally Owned Accounts) - `kmsKey` `string`: KMS key identifier (optional, for KMS-managed accounts) - `share` `string`: The encrypted share repository data (optional, for embedded signers) ### Responses #### `200`: Successful response. Body (`application/json`): - `share` `string`: The encrypted share repository data used for key management - `accountType` `string` _(required)_: Type of account (Smart Account or Externally Owned Account) - `implementationType` `string`: Smart account implementation type (only for Smart Accounts) - `implementationAddress` `string`: Implementation contract address (only for Smart Accounts) - `factoryAddress` `string`: Factory contract address (only for Smart Accounts) - `salt` `string`: Salt used for smart account deployment (only for Smart Accounts) - `address` `string` _(required)_: Account blockchain address - `ownerAddress` `string` _(required)_: Owner address of the account - `chainType` `string` _(required)_: Chain type (EVM or SVM) - `chainId` `number`: Chain ID - `device` `string`: Device identifier - `account` `string` _(required)_: Account ID (starts with acc_) - `signer` `string` _(required)_: Signer identifier #### `401`: Error response - Unauthorized #### `415`: Unsupported Media Type - a request with a body must use Content-Type application/json. #### `429`: Too Many Requests - per-user rate limit exceeded. Retry after the interval in the Retry-After header. ### Example request ```bash curl http://localhost:7054/v2/devices/create \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "accountType": "Smart Account", "chainType": "EVM", "address": "0xf7b4c54cca21cccf42796502bf94e2838fbd44c4", "chainId": 80002, "privateKey": "0x...", "kmsKey": "kms_...", "share": "7d526b7e99fbf52850a183..." }' ``` ```ts fetch('http://localhost:7054/v2/devices/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountType: 'Smart Account', chainType: 'EVM', address: '0xf7b4c54cca21cccf42796502bf94e2838fbd44c4', chainId: 80002, privateKey: '0x...', kmsKey: 'kms_...', share: '7d526b7e99fbf52850a183...' }) }) ``` ## Initialize device registration or recovery `POST /v1/devices/init` Determines whether a user needs to register a new device or recover an existing one for a given chain. Returns `REGISTER` if no account exists for the user on the specified chain, or `RECOVER` with the primary device share if an account already exists. ### Header parameters - `X-Auth-Provider` `string`: Selects how the bearer token is validated. Omit it to use the default provider, which verifies the token against the auth service's JWKS. ### Request body (required) (`application/json`) - `chainId` `integer ` _(required)_: The chain ID to initialize for. ### Responses #### `200`: Successful response. Body (`application/json`): - `nextAction` `string` _(required)_: The action the client should take next. - `player` `string` _(required)_: The authenticated user ID. - `embedded` `object`: Device and share data (present when nextAction is RECOVER). - `share` `string`: Decrypted share (only for RECOVER). - `ownerAddress` `string`: Owner address. - `address` `string`: Account address (only for RECOVER). - `chainId` `integer `: Chain ID. - `deviceId` `string`: Device identifier. #### `401`: Error response - Unauthorized #### `415`: Unsupported Media Type - a request with a body must use Content-Type application/json. #### `429`: Too Many Requests - per-user rate limit exceeded. Retry after the interval in the Retry-After header. ### Example request ```bash curl http://localhost:7054/v1/devices/init \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "chainId": 80002 }' ``` ```ts fetch('http://localhost:7054/v1/devices/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: 80002 }) }) ``` ## Register a device (v1) `POST /v1/devices/register` Registers a new device for a user. If no account exists for the user on the given chain and address, a new account is created and the device is marked as primary. Otherwise, the device is added as a secondary device. ### Header parameters - `X-Auth-Provider` `string`: Selects how the bearer token is validated. Omit it to use the default provider, which verifies the token against the auth service's JWKS. ### Request body (required) (`application/json`) - `chainId` `integer ` _(required)_: The chain ID. - `address` `string` _(required)_: Blockchain address. - `share` `string` _(required)_: The encrypted share data. - `signerUuid` `string`: Signer UUID to assign (optional, generated if omitted). ### Responses #### `200`: Successful response. Body (`application/json`): - `share` `string`: The encrypted share data. - `address` `string`: Account blockchain address. - `chainId` `integer `: Chain ID. - `deviceId` `string`: Device identifier. - `device` `string`: Device ID. - `account` `string`: Account ID (starts with acc_). - `ownerAddress` `string`: Owner address of the account. - `accountType` `string`: Type of account. - `signer` `string`: Signer identifier (starts with sig_). #### `401`: Error response - Unauthorized #### `415`: Unsupported Media Type - a request with a body must use Content-Type application/json. #### `429`: Too Many Requests - per-user rate limit exceeded. Retry after the interval in the Retry-After header. ### Example request ```bash curl http://localhost:7054/v1/devices/register \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "chainId": 80002, "address": "0xf7b4c54cca21cccf42796502bf94e2838fbd44c4", "share": "7d526b7e99fbf52850a183...", "signerUuid": "string" }' ``` ```ts fetch('http://localhost:7054/v1/devices/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: 80002, address: '0xf7b4c54cca21cccf42796502bf94e2838fbd44c4', share: '7d526b7e99fbf52850a183...', signerUuid: 'string' }) }) ``` ## Get a device by ID `GET /v1/devices/{deviceId}` Returns details for a specific device, including its decrypted share. Use the special value `primary` as the device ID to retrieve the primary device for the authenticated user. ### Path parameters - `deviceId` `string` _(required)_: The device ID, or `primary` to get the primary device. ### Header parameters - `X-Auth-Provider` `string`: Selects how the bearer token is validated. Omit it to use the default provider, which verifies the token against the auth service's JWKS. ### Responses #### `200`: Successful response. Body (`application/json`): - `id` `string` _(required)_: Unique device identifier. - `object` `string` _(required)_: Object type indicator. - `createdAt` `integer ` _(required)_: Device creation timestamp (Unix seconds). - `address` `string`: Account address (present when retrieving primary device). - `share` `string` _(required)_: Decrypted share data. - `isPrimary` `boolean` _(required)_: Whether this is the primary device for the account. #### `401`: Error response - Unauthorized #### `404`: Device not found. #### `429`: Too Many Requests - per-user rate limit exceeded. Retry after the interval in the Retry-After header. ### Example request ```bash curl http://localhost:7054/v1/devices/string ``` ```ts fetch('http://localhost:7054/v1/devices/string') ``` ## List devices for the authenticated user `GET /v1/devices` Returns a paginated list of devices belonging to the authenticated user across all their accounts. ### Query parameters - `limit` `integer `: Maximum number of devices to return (default 100, max 100). ### Header parameters - `X-Auth-Provider` `string`: Selects how the bearer token is validated. Omit it to use the default provider, which verifies the token against the auth service's JWKS. ### Responses #### `200`: Successful response. Body (`application/json`): - `object` `string` _(required)_: Response type indicator. - `url` `string` _(required)_: URL of the list endpoint. - `data` `object[]` _(required)_: Array of device records. - `id` `string` _(required)_: Unique device identifier. - `object` `string` _(required)_: Object type indicator. - `createdAt` `integer ` _(required)_: Device creation timestamp (Unix seconds). - `address` `string`: Account address (present when retrieving primary device). - `share` `string` _(required)_: Decrypted share data. - `isPrimary` `boolean` _(required)_: Whether this is the primary device for the account. - `start` `integer ` _(required)_: Starting index of the results. - `end` `integer ` _(required)_: Ending index of the results. - `total` `integer ` _(required)_: Total number of records available. #### `401`: Error response - Unauthorized #### `429`: Too Many Requests - per-user rate limit exceeded. Retry after the interval in the Retry-After header. ### Example request ```bash curl 'http://localhost:7054/v1/devices?limit=0' ``` ```ts fetch('http://localhost:7054/v1/devices?limit=0') ``` ## Create a device for an existing account `POST /v1/devices` Creates a new device and associates it with an existing account. The share is encrypted and stored. ### Header parameters - `X-Auth-Provider` `string`: Selects how the bearer token is validated. Omit it to use the default provider, which verifies the token against the auth service's JWKS. ### Request body (required) (`application/json`) - `accountId` `string` _(required)_: The account ID to associate the device with. - `address` `string` _(required)_: Blockchain address. - `chainId` `integer ` _(required)_: The chain ID. - `share` `string` _(required)_: The encrypted share data. ### Responses #### `200`: Successful response. Body (`application/json`): - `id` `string` _(required)_: Unique device identifier. - `object` `string` _(required)_: Object type indicator. - `createdAt` `integer ` _(required)_: Device creation timestamp (Unix seconds). - `address` `string`: Account address (present when retrieving primary device). - `share` `string` _(required)_: Decrypted share data. - `isPrimary` `boolean` _(required)_: Whether this is the primary device for the account. #### `400`: Account not found. #### `401`: Error response - Unauthorized #### `415`: Unsupported Media Type - a request with a body must use Content-Type application/json. #### `429`: Too Many Requests - per-user rate limit exceeded. Retry after the interval in the Retry-After header. ### Example request ```bash curl http://localhost:7054/v1/devices \ --request POST \ --header 'Content-Type: application/json' \ --data '{ "accountId": "acc_6f6c9067-89fa-4fc8-ac72-c242a268c584", "address": "0xf7b4c54cca21cccf42796502bf94e2838fbd44c4", "chainId": 80002, "share": "7d526b7e99fbf52850a183..." }' ``` ```ts fetch('http://localhost:7054/v1/devices', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId: 'acc_6f6c9067-89fa-4fc8-ac72-c242a268c584', address: '0xf7b4c54cca21cccf42796502bf94e2838fbd44c4', chainId: 80002, share: '7d526b7e99fbf52850a183...' }) }) ```