11Docs

Auth

Bounded has two distinct identity systems: your developer web account (owns and administers apps) 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 developer login with end-users.

The two identity systems

WhoWhat it isWhere it shows up
Dev identityyou / your agentnormally a Bounded web session selected by bounded initowns 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

Developer identity

bounded init reuses or refreshes your saved web session, opens hosted browser login when needed, and writes public bounded.json. Tokens stay outside the project. Local signing profiles and BOUNDED_PRIVATE_KEY are advanced explicit alternatives for key-owned or automated workflows.

bounded whoami   # developer identity, environment, account source

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.

Advanced local keys & teams

Normal projects can skip local keys and linking. If you deliberately selected a local signing key for a legacy or automated workflow, you can link it for recovery. Sharing teammates is available to web-account and key-owned apps:

  • bounded link runs an OAuth-style device flow: the CLI prints a verify URL + code, you approve in a browser signed in as your account — email/social or a Solana wallet — 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 account’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.
  • 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. With embedded wallets on (the default), @user.address is their provisioned wallet; opt out and it is normally null unless a user links or brings 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. It is opt-in on both sides: the client config needs walletLogin: true to offer the lane, and the app’s policy.json needs an explicit top-level { "auth": { "wallets": true } } before the server will mint a wallet session — without it /session refuses with 403 wallet_login_disabled. Unlike embedded wallets, this opt-in has no default-on behaviour: a bare signature mints a real session, so an app must declare it. 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 the embedded wallets below, which give an email user a device-passkey Turnkey wallet — 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 (default-on)

Normal apps get embedded wallets by default: 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 would otherwise have no wallet, gets one, and Bounded never holds the key. Turn it off per app with a top-level auth block set to { "wallets": false }.

policy.json - wallets are on by default; this collection keys ownership on the wallet
{
  "notes/$id": {
    "rules": {
      "read": "true",
      "create": "@user.address != null && @newData.owner == @user.address"
    },
    "fields": { "owner": "String", "text": "String" }
  }
}
  • Wallets are Turnkey wallets whose only signer is a device passkey (Face ID / Touch ID / WebAuthn) held on the user’s device. Bounded’s server can create and read the address but holds no key in the wallet’s signing quorum, so it can neither sign nor recover unilaterally.
  • One wallet per identity, platform-wide. Passkey creation and signing run through one Bounded-owned origin, so the same user gets the same address across every app on any domain. Re-login and token refresh keep the same @user.address.
  • Default-on, additive. Set { "auth": { "wallets": false } } to turn embedded wallets off for an app; then @user.address stays null for email logins. Login never blocks on wallet provisioning, so @user.address can still be null immediately after a brand-new email’s first login. Guard sign actions on getCurrentUser()?.address and treat a null address as “not provisioned yet.” Note the same key gates the separate wallet-login lane with the opposite default: embedded provisioning is on unless you set false, while wallet login stays off until you set true.
  • Signing (raw and submit). An email user can signMessage(message), signTransaction(tx) (sign without submitting), or signAndSubmitTransaction(tx) (sign and submit, resolving with the on-chain tx hash) with their embedded wallet — the same client signing API a wallet-login user has. Signing runs through a single Bounded-owned signer surface that prompts the OS passkey sheet, so call it from a user gesture. Each request is gated by a short-lived signing capability bound to the app, the requesting origin, and the exact payload, so a page cannot phish one passkey approval into a signature over other bytes. Bounded never holds the key.
  • Wallet page. Bounded hosts a ready-made page at auth.bounded.sh/wallet where a user signs in with their email to view their balance (USDC / SOL) and send tokens to another address — showing the submitted tx hash and an explorer link — plus a passkey-gated private-key export. 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.
  • Scope: Solana only; email-carrying logins only (phone-only / guest sessions get no embedded wallet).

Payments belong to the app, not the auth page

Wallet identity can support a payment flow, but it is not the payment contract. Bounded apps can accept direct USDC on Solana or use a provider integrated in a Function. Each path has different custody, fees, lifecycle, and reconciliation semantics.

Read Payments for payments.acceptCrypto, direct-provider integrations, and the rule that app value is granted only after trusted settlement.

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.