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

# Sign in with Starknet

> Let users sign in with the wallet they already have (Bramble, Braavos, Ready): a SNIP-12 message, a one-time nonce, and a check by the account itself. No email, no password.

Starknet users already hold a key they trust. **Sign in with Starknet** (SIWS) turns that key into a login: your server issues a one-time nonce, the user's wallet signs a SNIP-12 message containing it, and your server asks the account contract whether the signature is valid.

`@chipi-stack/backend` (14.16.0+) ships both halves:

* `createStarknetSignInTypedData` builds the message.
* `verifyStarknetSignIn` checks it: chain, domain, URI, nonce, expiry, and the signature.

It works with deployed accounts (any wallet, through the account's own `is_valid_signature`) and with **accounts that are not deployed yet**, which is how new Bramble and Ready accounts start out.

The browser example uses Bramble. Braavos and Ready take the same requests; only discovery differs.

SIWS is mainnet only. The domain's `chainId` must be `SN_MAIN`; from 14.17.0 its felt form (`0x534e5f4d41494e`, hex or decimal) is accepted too, since wallets echo either.

## 1. Issue the message (server)

Store the nonce with a short expiry, then return the typed data to the browser.

```ts theme={null}
import { createStarknetSignInTypedData } from "@chipi-stack/backend";

const nonce = crypto.randomUUID().replaceAll("-", "");
const now = Math.floor(Date.now() / 1000);

const typedData = createStarknetSignInTypedData({
  domain: "app.example.com",
  uri: "https://app.example.com",
  address: "0x0123", // the address Bramble returned from wallet_requestAccounts
  nonce,
  issuedAt: now,
  expirationTime: now + 600,
});
```

The builder sticks to what every wallet accepts: SNIP-12 revision 1, `SN_MAIN` in the domain, and single-line printable ASCII strings. It throws on anything else, because some wallets (Bramble among them) refuse to sign it.

## 2. Sign it (browser)

Bramble injects `window.starknet_bramble` (id `"bramble"`). It does not set `window.starknet`, and it can appear after `DOMContentLoaded`, so wait for its `starknet#initialized` event before deciding it is missing.

Bramble is strict, and the snippet follows its rules:

* **Prompts need a click.** Bramble opens its panel only during a user gesture, so start from a click handler. Each prompt stays pending until the user decides; don't retry it automatically.
* **Params are parsed strictly.** Pass `{ silent_mode: false }` to `wallet_requestAccounts`, never `undefined` params or extra keys (`114`).
* **Mainnet first.** Bramble refuses typed data whose domain `chainId` is not its active network (`114`, before any prompt). Check `wallet_requestChainId` before you ask for a signature.
* **Deployment data.** `wallet_deploymentData` answers `115` when the account is already deployed (send nothing) and `116` when Bramble can't derive it.

```ts theme={null}
type InjectedWallet = {
  id: string;
  request(call: { type: string; params?: unknown }): Promise<unknown>;
};
type WalletError = { code?: number; message?: string };

declare const signInButton: HTMLButtonElement;
declare function showMessage(text: string): void;

const SN_MAIN = "0x534e5f4d41494e";

// Bramble's codes, in words a user can act on.
const WALLET_ERRORS: Record<number, string> = {
  113: "You declined the request in Bramble.",
  114: "Bramble refused the request. Make sure it is on Starknet Mainnet, then try again.",
  163: "Bramble couldn't complete the request. Unlock it and check this site is connected.",
};

// Late injection: look now, then on `starknet#initialized`, polling as a fallback.
function waitForBramble(timeoutMs = 3000): Promise<InjectedWallet | null> {
  const found = () =>
    (window as unknown as { starknet_bramble?: InjectedWallet }).starknet_bramble ?? null;
  return new Promise((resolve) => {
    if (found()) return resolve(found());
    const done = () => {
      clearInterval(poll);
      clearTimeout(timer);
      window.removeEventListener("starknet#initialized", check);
      resolve(found());
    };
    const check = () => {
      if (found()) done();
    };
    window.addEventListener("starknet#initialized", check);
    const poll = setInterval(check, 100);
    const timer = setTimeout(done, timeoutMs);
  });
}

signInButton.onclick = async () => {
  const bramble = await waitForBramble();
  if (!bramble) return showMessage("Install Bramble to sign in with Starknet.");

  try {
    // 1. Mainnet only. This works before connecting and never prompts.
    // Bramble answers in hex; accept the short string too for other wallets.
    const chainId = (await bramble.request({ type: "wallet_requestChainId" })) as string;
    const onMainnet = chainId === "SN_MAIN" || (/^0x[0-9a-f]+$/i.test(chainId) && BigInt(chainId) === BigInt(SN_MAIN));
    if (!onMainnet) {
      return showMessage("Switch Bramble to Starknet Mainnet, then sign in again.");
    }

    // 2. Connect. Explicit params; the promise waits for the user's decision.
    const [address] = (await bramble.request({
      type: "wallet_requestAccounts",
      params: { silent_mode: false },
    })) as string[];
    if (!address) return showMessage("No Bramble account connected.");

    // 3. Your server issues the message for this address (step 1).
    const issued = await fetch("/api/siws/nonce", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ address }),
    });
    const { typedData } = (await issued.json()) as { typedData: unknown };

    // 4. Sign. Bramble shows the message and waits for the user.
    const signature = (await bramble.request({
      type: "wallet_signTypedData",
      params: typedData,
    })) as string[];

    // 5. A new account is not deployed yet: send its deployment data along.
    let deploymentData: unknown;
    try {
      deploymentData = await bramble.request({ type: "wallet_deploymentData" });
    } catch (err) {
      const code = (err as WalletError).code;
      // 115: already deployed, nothing to send. 116: Bramble can't derive it.
      if (code !== 115 && code !== 116) throw err;
    }

    // 6. Your server verifies (step 3).
    const verified = await fetch("/api/siws/verify", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ typedData, signature, deploymentData }),
    });
    const result = (await verified.json()) as { ok: boolean; reason?: string };
    if (!result.ok && result.reason === "ACCOUNT_NOT_DEPLOYED") {
      return showMessage("Make one transaction with this account first, then sign in.");
    }
    showMessage(result.ok ? "Signed in." : "Sign-in failed. Try again.");
  } catch (err) {
    const code = (err as WalletError).code;
    showMessage((code !== undefined && WALLET_ERRORS[code]) || "Sign-in failed. Try again.");
  }
};
```

A locked wallet, an unconnected site and a refused request all reach the page as `163` with no detail, so show one message with a manual retry button.

This snippet connects and signs from one click. Bramble opens its panel only during a user gesture, and the click's gesture can expire while the user approves the connection. If the signature prompt doesn't open, split the flow into two clicks: one to connect, one to sign. The signature is `[r, s]` for Bramble's OpenZeppelin account and Braavos, and `["1", "0", publicKey, r, s]` for Ready (Argent). Send it as returned; the server passes the whole array to the account.

With get-starknet v4, `getAvailableWallets()` finds Bramble by `id === "bramble"`; run it after injection. starknetkit needs Bramble added as an explicit `InjectedConnector`.

## 3. Verify (server)

Consume the nonce (once, atomically), then verify:

```ts theme={null}
import { verifyStarknetSignIn, type StarknetDeploymentData } from "@chipi-stack/backend";
import { RpcProvider, type TypedData } from "starknet";

declare const typedData: TypedData;
declare const signature: string[];
declare const nonce: string;
declare const deploymentData: StarknetDeploymentData | undefined;

const provider = new RpcProvider({ nodeUrl: "https://api.cartridge.gg/x/starknet/mainnet" });

const result = await verifyStarknetSignIn({
  typedData,
  signature,
  provider,
  expected: { domain: "app.example.com", uri: "https://app.example.com", nonce },
  deploymentData,
});

if (result.ok) {
  // result.address: 0x + 64 hex. Sign the user in, or link the address to the current user.
} else {
  // result.reason: WRONG_NONCE, EXPIRED, INVALID_SIGNATURE, ACCOUNT_NOT_DEPLOYED, ...
}
```

What it checks:

| Check | Fails with |
| - | - |
| The typed data has a message and the signature is a non-empty array | `MALFORMED` |
| Domain chain is `SN_MAIN`, as the short string or its felt (`0x534e5f4d41494e`) | `WRONG_CHAIN` |
| The message rebuilds: printable ASCII strings, a nonce of 8+ letters or digits, whole-second times, expiry after issue | `MALFORMED` |
| `domain`, `uri`, `nonce` are the ones you issued | `WRONG_DOMAIN`, `WRONG_URI`, `WRONG_NONCE` |
| Not expired, not issued in the future (60 s skew) | `EXPIRED`, `NOT_YET_VALID` |
| Deployed account: `is_valid_signature` returns `'VALID'` | `INVALID_SIGNATURE` |
| Undeployed account with no `deploymentData` | `ACCOUNT_NOT_DEPLOYED` |
| Undeployed account: `deploymentData.address` is the address that signed in | `ADDRESS_MISMATCH` |
| Undeployed account: a class we can read, with no guardian | `UNSUPPORTED_ACCOUNT_CLASS` |
| Undeployed account: `class_hash`, `salt` and `calldata` deploy to `deploymentData.address` | `ADDRESS_MISMATCH` |
| Undeployed account: the owner key signed, in the class's signature shape | `INVALID_SIGNATURE` |

`ADDRESS_MISMATCH` has two sources: deployment data for another account than the one in the message, or deployment data whose constructor arguments don't produce its own address.

The typed data is rebuilt from its message before hashing, so a client can't swap in extra types or a testnet domain. Only `'VALID'` counts as valid: accounts answer `0` or revert on a bad signature, and a looser "anything non-zero" check would accept garbage.

Undeployed accounts are supported for **OpenZeppelin 3.0** (Bramble's default account, signature `[r, s]`) and **Argent 0.4 without a guardian** (Ready, signature `["1", "0", publicKey, r, s]`). An undeployed Argent account with a guardian gets `UNSUPPORTED_ACCOUNT_CLASS`, because its second signature can't be checked off-chain; so does any other class. Ask the user to make one transaction first: Bramble deploys an OpenZeppelin or Argent account in the same flow as its first transaction.

## Lower-level helpers

* `isValidStarknetSignature(provider, address, hash, signature)` asks any deployed account about any message hash.
* `verifyUndeployedStarknetSignature(deploymentData, hash, signature)` does the off-chain check on its own.

## Related

* [Trusted wallets](/sdk/guides/trusted-wallets): make Bramble a co-owner or guardian of a Chipi wallet
* [Passkeys](/sdk/guides/passkeys)
* [Connect with Chipi](/sdk/connector/overview)

> ✅ Verified against the SDK source on **2026-10-12**.


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