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

WhoWhat it isWhere it shows up
Dev identityyou / your agentan ed25519 keypair the CLI and @bounded-sh/server sign withowns apps; the actor bounded deploy / data run as
End-user authyour app's usersBounded 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 link runs 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 use bounded 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. policy is a legacy alias for developer. 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.

client auth, hosted login
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.address is normally null, but auth.wallets can 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.id equals the real wallet address and @user.address is that same wallet, and the user signs locally with their own keypair (signMessage / signTransaction). This is distinct from auth.wallets embedded 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.id for ownership, membership, and auth guards. Use @user.address only 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.

policy.json
{
  "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.address stays null for 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.address lands on the user’s next login. Guard sign actions on getCurrentUser()?.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=staging for 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.

policy.json
{
  "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 is usdc. environment (default production) can be staging for 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 naming settleTo + amount. The buyer pays that address (wallet page, or any wallet). Your app POST /crypto/intents/:id/verify { txSignature }; Bounded verifies on-chain (finalized, correct mint, amount received at settleTo, one signature settles exactly one intent) and records a settlement. GET /crypto/intents/:id polls 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 same payments.acceptCrypto block, 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:

VariableWhat it isUse it for
@user.idThe 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.addressThe 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.emailThe 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.

server client
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.