11Docs
Auth
Bounded has two distinct identity systems: your dev keypair (owns apps, signs deploys and CLI writes) and your app’s end-users. An end-user surfaces in rules as three variables, @user.id (their universal identity, always present, use it for ownership), @user.address (their real wallet, for onchain), and @user.email (verified email, when present). Don’t conflate the dev keypair with end-users.
The two identity systems
| Who | What it is | Where it shows up | |
|---|---|---|---|
| Dev identity | you / your agent | an ed25519 keypair the CLI and @bounded-sh/server sign with | owns apps; the actor bounded deploy / data run as |
| End-user auth | your app's users | Bounded Auth hosted login, anonymous guests, or a connected wallet (Phantom) | @user.id, @user.address, and @user.email in policy rules |
Dev identity, the keypair is your account
There is no login step for building. bounded init writes public bounded.json; the first command that needs auth generates or loads the selected key source. By default that is ~/.bounded/credentials, but a project can select a named profile, a project-scoped key, or BOUNDED_PRIVATE_KEY. That keypair is the identity, owns every app you create, and signs every write.
bounded whoami # address, environment, key source (creates the key if absent)Use bounded account use client-a to run this project under ~/.bounded/accounts/client-a/credentials, bounded account use --project for <project>/.bounded/credentials, or override with BOUNDED_PRIVATE_KEY for CI. Never reuse a human’s keypair for an autonomous agent unless that is explicitly intended.
Linking & teams
The keypair never needs a human account to build, verify, deploy, or read/write. But you can link it to an email account and share apps with teammates:
bounded linkruns an OAuth-style device flow: the CLI prints a verify URL + code, you approve in a browser with your email account, and the CLI records the linkage. Headless agents can usebounded link --email you@example.com: the CLI emails an OTP, reads it from stdin, and approves the same fingerprint checked device flow. After linking, your keypair address and your email’s wallet become admin-collaborators on each other’s apps. Your keypair keeps signing for everything, linking adds an association, it never replaces or rolls your key. Linking is required only for billing / dashboard / teams.bounded share <wallet|email> --role developer|admin|viewer|billing --app-id <id>adds a collaborator. A wallet is added directly. An email is resolved to that person’s canonical wallet, an auto-provisioned embedded wallet, so the invitee needs no wallet of their own.policyis a legacy alias fordeveloper. Owner only.
End-user auth - hosted login, guests, and wallets
Your app’s users authenticate through @bounded-sh/client. Bounded Auth is the default for normal apps: email OTP, OAuth/social login, and optional text OTP all run through hosted OAuth2 + PKCE on auth.bounded.sh.
import { init, loginWithRedirect, completeLoginFromRedirect, getCurrentUser, useAuth } from "@bounded-sh/client";
await init({ appId: "<appId>" });
await loginWithRedirect({ redirectUri: "https://yourapp.com/auth/callback" });
// On your callback route:
const user = await completeLoginFromRedirect();
const current = getCurrentUser(); // { id, address, email, isAnonymous } | null
// React:
function AuthButton() {
const { user, logout, loading } = useAuth();
if (loading) return null;
return user
? <button onClick={logout}>{user.id.slice(0, 6)}...</button>
: <button onClick={() => loginWithRedirect({ redirectUri: "https://yourapp.com/auth/callback" })}>Sign in</button>;
}Use loginWithRedirect or loginWithPopup for human login. Guest accounts use signInAnonymously(), not an authMethod. authMethod: 'phantom' lets a user connect their own Solana wallet (Phantom / any Wallet-Standard wallet) — opt-in, off by default: turn it on at init() with walletLogin: true. Their real wallet becomes @user.address with a full local signing surface (signMessage / signTransaction / signAndSubmitTransaction). The current default entry has no authMethod: 'none' provider; simply do not start a login flow on a public-read app. 'wallet' is an alias for 'phantom'.
- Bounded Auth signs users in through the hosted email, social, or optional text flow. These users always have a stable
@user.id.@user.addressis normally null, butauth.walletscan provision one after login and a user may also link or bring a wallet; never use it as the ordinary identity key. - Guest users sign in with
signInAnonymously(). They get a stable guest@user.id, and later hosted login returns the real account identity. - Wallet login (Phantom) connects an existing Solana wallet directly — opt-in via
walletLogin: true. Here@user.idequals the real wallet address and@user.addressis that same wallet, and the user signs locally with their own keypair (signMessage/signTransaction). This is distinct fromauth.walletsembedded wallets below, which give an email user a Crossmint smart wallet (sign-and-submit only, via popup) — a wallet-login user’s real address is not overwritten. - Whatever the route, use
@user.idfor ownership, membership, and auth guards. Use@user.addressonly for wallet or onchain semantics.
Imperative equivalents to useAuth: onAuthStateChanged(cb), onAuthLoadingChanged(cb), logout(). Use loginWithRedirect or loginWithPopup to start sign-in.
Embedded wallets: a wallet on every login (auth.wallets)
Opt in with a top-level auth block and every email-carrying login also gets a non-custodial Solana wallet, with its address exposed to your rules as @user.address. So even a plain email/social user, who normally has no wallet, gets one, and Bounded never holds the key.
{
"auth": { "wallets": true },
"notes/$id": {
"rules": {
"read": "true",
"create": "@user.address != null && @newData.owner == @user.address"
},
"fields": { "owner": "String", "text": "String" }
}
}- Wallets are Crossmint smart wallets with an email admin signer. The user authorizes signatures client-side (email OTP / passkey); Bounded’s server can create and read the address but cannot move funds.
- One wallet per identity, platform-wide, the same user gets the same address across every wallets-enabled app. Re-login and token refresh keep the same
@user.address. - The Crossmint environment is a policy setting:
{ "wallets": { "environment": "staging" } }uses test wallets,{ "wallets": true }(or"production") uses real ones. Staging and production are separate worlds, so the same user has a different address in each. - Opt-in, additive, fail-open. No
auth.wallets, no wallet,@user.addressstaysnullfor email logins (unchanged). Login never blocks on wallet creation: on a brand-new email, provisioning takes a few seconds, so the wallet is created in the background and@user.addresslands on the user’s next login. Guard sign actions ongetCurrentUser()?.address. - Signing (app-built transactions). An email user can build a real Solana transaction in your app and approve+submit it with their wallet via
signAndSubmitTransaction(tx), which resolves with the on-chain tx hash. Build a v0 transaction with the smart wallet as the fee payer, unsigned and with no extra signers — Crossmint takes your instructions, sponsors the gas, and re-wraps them for the smart wallet. Signing runs in a Bounded-hosted popup that does a one-time email verification per device (first sign = two codes, then none); Bounded never holds the key. Call it from a user gesture (it opens a popup).signMessage/signTransaction(sign without submit) throw a clear “unsupported for embedded smart wallets” error — Crossmint smart wallets sign and submit atomically. - Wallet page. Bounded hosts a ready-made page at
auth.bounded.sh/wallet(add?env=stagingfor test wallets) where a user signs in with their email to view their balance (USDC / SOL; USDXM on staging) and send tokens to another address — showing the submitted tx hash and an explorer link. The cloud dashboard links to it from the header. There is no built-in fiat offramp yet: the interim cash-out path is to send USDC to your exchange deposit address and withdraw to fiat there (a native offramp is a KYC-gated Crossmint product, planned later). - Scope (v1): Solana only; email-carrying logins only (phone-only / guest sessions get no embedded wallet).
Accept crypto: get paid USDC, non-custodially (payments.acceptCrypto)
Declare one policy block and your app accepts USDC on Solana, paid directly to a wallet address you own. Bounded verifies the payment on-chain and (optionally) emails you when it lands — but never holds your keys or funds.
{
"payments": {
"acceptCrypto": {
"settleTo": "89MCqR8aE7HfJudgDxD9ujsfRQvWw7kh44jXX1TA3wWB",
"token": "usdc",
"environment": "production",
"notify": "seller@example.com"
}
}
}- settleTo (required) is a base58 Solana address you own: paste your embedded-wallet address from
auth.bounded.sh/wallet, or any wallet / PDA. Buyers pay it directly. token isusdc. environment (defaultproduction) can bestagingfor devnet testing. notify is an optional email for a “you received X USDC” message on settlement. - The flow. Your app
POST /crypto/intents{ appId, amountUsdc }→ gets an unguessable intent namingsettleTo+ amount. The buyer pays that address (wallet page, or any wallet). Your appPOST /crypto/intents/:id/verify{ txSignature }; Bounded verifies on-chain (finalized, correct mint, amount received atsettleTo, one signature settles exactly one intent) and records a settlement.GET /crypto/intents/:idpolls status. It’s a control-plane block — zero prover impact, no change to your collections. - Fee is 0 on this direct-transfer rail (a direct payment can’t be split). Bounded never takes a fee by touching your funds.
- Crypto vs. Bounded Pay. Two separate, coexisting pipelines — the same app can enable both. Accept crypto = buyer pays USDC directly to your wallet, non-custodial, 0 fee, no onboarding. Bounded Pay = buyer pays by card, Stripe processes and pays out fiat, 1% platform fee. Neither touches the other’s money or config.
- Card → crypto rail (Crossmint checkout). A credit-card buyer rail — buyer pays by card, Apple Pay, or Google Pay, seller still receives USDC to
settleTo— is built and staging-proven behind a platform flag, with production activation pending Crossmint commercial enablement. Sellers change nothing: the samepayments.acceptCryptoblock, and the Crossmint checkout settles through the same verify → settle seam (feeBps 0, non-custodial). Buyers complete an embedded Crossmint checkout with Crossmint’s own KYC. For card-to-fiat today, use Bounded Pay. Cash out by sending USDC to your exchange deposit address from the wallet page. - Card-rail approval & volume caps. Because all card-funded volume rides Bounded’s single Crossmint account, the card rail is subject to platform approval and per-app volume caps (per-order, daily, and 7-day limits) — Bounded may require approval, throttle, or block card-rail use per app to protect the shared account. The direct wallet-to-wallet rail is permissionless: no approval, no caps, no Bounded-account risk — the buyer pays your address directly on-chain and Bounded only verifies. Use the direct rail for a fully permissionless crypto-accept experience.
How the user reaches your rules: @user.id, @user.address, @user.email
Every authenticated request carries a session token bound to the app (the App-Id audience). The realtime worker verifies it and exposes the end-user to the policy as three variables:
| Variable | What it is | Use it for |
|---|---|---|
@user.id | The universal, stable identity. ALWAYS present for an authenticated user (null otherwise). For wallet (Phantom) logins it equals the wallet address; for Bounded Auth logins it is the account identity. | Ownership & membership. This is the right key for "who owns this". |
@user.address | The user’s REAL wallet address. Present for wallet (Phantom) and server-keypair sessions; null for Bounded Auth sessions unless a wallet is linked. | Onchain operations only (transfers, fee payer). Onchain rules may use ONLY @user.address. @user.id and @user.email are rejected there. |
@user.email | The verified, lowercased email for email/OAuth accounts; null for wallet and phone-only text sessions. | Email-gating. Store compared email fields lowercased so equality matches. |
Ownership is the hinge of most auth rules. Key it on @user.id so it works for every login method (a wallet user and an email user are both covered, and an email user with no wallet still owns their data):
"create": "@user.id != null && @newData.owner == @user.id"The leading @user.id != null is mandatory, without it an unauthenticated caller writing owner: null satisfies null == null. The prover hands you that exact counterexample if you forget it (Verification).
Server-side identity
On a server, the same kind of keypair drives @bounded-sh/server. Server-signed writes arrive with the keypair’s address as @user.address (== @user.id), so server logic is just another authenticated actor the rules judge, give the vault key exactly the access its rules require, no more.
import { createWalletClient } from "@bounded-sh/server";
const vault = await createWalletClient({ keypair: process.env.VAULT_KEY! }); // base58 or JSON array
vault.address; // the signer this app acts as, arrives in rules as @user.address (== @user.id)Onchain & gas sponsorship
Bounded is offchain-first. For onchain apps, a verified subset of invariants enforces on Solana through a Kani-verified program, and the platform sponsors transaction fees so end-users don’t need SOL to interact through wallet sessions. Onchain policy updates require a human-signed step (deferred from the plain bounded deploy path); plan onchain policy changes as a signed action rather than a hands-off redeploy. Which invariants enforce onchain is detailed in Verification.