Wallet sign-in SDK
No built-in UI — the look and feel are yours. ChainBridge handles authentication and sessions. Browser and Node.js entry points are included, and mobile QR pairing runs over the official WalletConnect relay.
Setup steps
The wallet signs; your server binds the challenge and holds the metered API key. The WalletConnect project ID is only for public relay configuration and differs from the ChainBridge API key.
Treat any key in browser or mobile code as public.cs_live_xxx API keys in that form belong on your server only. QR pairing needs a public 32-character hex WalletConnect project ID; metered API keys and app sessions never leave your server. If a key leaks, disable it in the console right away and review usage.
Integration options
The fastest route is the script include — no build tool, no dependencies. Add the two lines below to your page and the button opens the ChainBridge wallet connect dialog. Copy the project ID in one click from the Wallet Connection page in the console. It is not the same as cs_live_ prefixed API keys — a key belongs on the server.
<script src="/site/sdk/connect.js"></script> <chainbridge-button project-id="CS-P-XXXXXX"></chainbridge-button>
To control timing, use JS: callChainBridge.init({ projectId }) then call ChainBridge.open(); the connect result arrives through the ChainBridge.on('connect', fn) callback { address, chainId, namespace, sessionNo }. Origins are enforced server-side: sites outside the allowlist get a 403 with a clear reason.
ChainBridge.init({ projectId: 'CS-P-XXXXXX' });
ChainBridge.on('connect', function (s) {
console.log(s.address, s.chainId, s.sessionNo);
});Build the browser example on this page into your app and wire it to the connect button. Save the server example below as server.mjs and start it with the key, platform URL and app origin configured. The example only exposes its own endpoints; your app must forward them on the same origin. In production use HTTPS, shared persistent session storage, and keep the challenge binding and origin checks.
export CS_API_KEY='your server-side billing key' export CS_BASE_URL='https://your-chainbridge-domain' export APP_ORIGIN='http://localhost:3000' node --experimental-strip-types server.mjs
Code examples
The browser gets its challenge from a same-origin server and the metered key stays on the server. The app paths in the examples below belong to your app, not to the platform API. The SDK shapes session data into fields such as sessionId, nonce and message; verification must use the same sessionId.
// This example is handled by the browser bundler; never create a billing-key client in the browser.
async function appJson(path, body, request) {
const response = await request(path, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const payload = await response.json();
if (!response.ok || payload.code !== 1) {
const error = new Error(payload.message || 'sign-in request did not succeed, please try again');
error.fromApp = true;
throw error;
}
return payload.data;
}
export async function signInWithProvider({ ethereum = window.ethereum, request = fetch } = {}) {
if (!ethereum) throw new Error('no wallet detected; install a wallet or choose QR sign-in');
try {
const accounts = await ethereum.request({ method: 'eth_requestAccounts' });
const address = accounts[0];
if (!/^0x[0-9a-fA-F]{40}$/.test(address || '')) throw new Error('invalid wallet account');
const chain = await ethereum.request({ method: 'eth_chainId' });
if (Number(chain) !== 1) {
const error = new Error('switch the wallet to Ethereum mainnet, then connect again');
error.fromApp = true;
throw error;
}
const challenge = await appJson('/api/connect/session', { address }, request);
// use the message exactly as issued; never rebuild the domain, nonce or expiry yourself.
const bytes = new TextEncoder().encode(challenge.message);
const hex = '0x' + Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
const signature = await ethereum.request({ method: 'personal_sign', params: [hex, address] });
return await appJson('/api/connect/verify', {
sessionId: challenge.sessionId, nonce: challenge.nonce,
message: challenge.message, address, signature
}, request);
} catch (error) {
if (error.code === 4001) throw new Error('the user cancelled the wallet connection or signature; please start again');
if (error.fromApp) throw error;
throw new Error('wallet connection or signature failed; check the wallet prompt and your network, then retry');
}
}
// call this from your connect button handler: await signInWithProvider().
import express from 'express';
import { randomBytes } from 'node:crypto';
import { createClient } from '@chainshield/nexus-sdk';
export function createConnectApp({
apiKey = process.env.CS_API_KEY, baseUrl = process.env.CS_BASE_URL,
origin = process.env.APP_ORIGIN, client
} = {}) {
if (!apiKey || !baseUrl || !origin) throw new Error('set the server key, platform URL and app origin first');
const site = new URL(origin);
if (site.origin !== origin || (site.protocol !== 'https:' && !(site.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(site.hostname)))) {
throw new Error('the app origin must be HTTPS; local development may use http://localhost or http://127.0.0.1');
}
const nexus = client || createClient({ apiKey, baseUrl, retries: 0 });
const app = express();
app.use(express.json({ limit: '16kb' }));
// single-instance example: in production use a shared persistent session store and keep the challenge binding rules below.
const sessions = new Map();
const cookieOptions = { httpOnly: true, sameSite: 'strict', secure: site.protocol === 'https:', path: '/', maxAge: 600000 };
const replyError = (res, status, message) => res.status(status).json({ code: 'APP_CONNECT_ERROR', message });
app.use((req, res, next) => {
res.set('Cache-Control', 'no-store');
if (req.method === 'POST' && (req.get('Origin') !== origin || !req.is('application/json'))) {
return replyError(res, 403, 'wrong request origin or content type; start again from the app page');
}
for (const [key, value] of sessions) if (value.expiresAt <= Date.now()) sessions.delete(key);
const rawCookie = String(req.headers.cookie || '').split(';').map(x => x.trim()).find(x => x.startsWith('cs_doc_session='));
let token = rawCookie ? rawCookie.slice('cs_doc_session='.length) : '';
let state = sessions.get(token);
if (!state) {
if (sessions.size >= 1000) return replyError(res, 503, 'too many connection requests; try again shortly');
token = randomBytes(32).toString('hex');
state = { expiresAt: Date.now() + 600000, pending: null, authenticated: null };
sessions.set(token, state);
res.cookie('cs_doc_session', token, cookieOptions);
}
req.connectState = state;
req.connectToken = token;
next();
});
const route = handler => (req, res, next) => Promise.resolve(handler(req, res)).catch(next);
app.post('/api/connect/session', route(async (req, res) => {
const address = req.body.address;
if (!/^0x[0-9a-fA-F]{40}$/.test(address || '')) return replyError(res, 400, 'provide a real wallet address');
const challenge = await nexus.connect.createSession({
domain: site.host, origin, chainId: 1, namespace: 'eip155', connectorKey: 'injected', address,
statement: 'Sign in to the example app. This signature does not send an on-chain transaction.'
});
if (sessions.get(req.connectToken) !== req.connectState || req.connectState.expiresAt <= Date.now()) {
return replyError(res, 409, 'the app session changed; reconnect the wallet');
}
req.connectState.pending = { ...challenge, address };
res.json({ code: 1, data: challenge });
}));
app.post('/api/connect/verify', route(async (req, res) => {
const pending = req.connectState.pending;
if (!pending || (pending.nonceExpiresAt && Date.parse(pending.nonceExpiresAt) <= Date.now())) {
return replyError(res, 400, 'this app session has no valid challenge; reconnect the wallet');
}
for (const key of ['sessionId', 'nonce', 'message', 'address']) {
if (req.body[key] !== undefined && req.body[key] !== pending[key]) return replyError(res, 400, 'the challenge changed; use the original message created in this session');
}
const result = await nexus.connect.verifySiwe({
sessionId: pending.sessionId, nonce: pending.nonce,
message: pending.message, address: pending.address, signature: req.body.signature
});
// logout, a new challenge or session expiry must invalidate any in-flight verification.
if (sessions.get(req.connectToken) !== req.connectState || req.connectState.pending !== pending ||
req.connectState.expiresAt <= Date.now() || (pending.nonceExpiresAt && Date.parse(pending.nonceExpiresAt) <= Date.now())) {
return replyError(res, 409, 'the app session or challenge changed; reconnect the wallet');
}
if (!result.ok || result.address.toLowerCase() !== pending.address.toLowerCase()) return replyError(res, 401, 'the signed identity does not match this wallet');
const authenticated = { address: result.address, sessionId: pending.sessionId };
// after sign-in, rotate your own app session; the platform verification result is not an app cookie or JWT.
sessions.delete(req.connectToken);
const token = randomBytes(32).toString('hex');
sessions.set(token, { expiresAt: Date.now() + 600000, pending: null, authenticated });
res.cookie('cs_doc_session', token, cookieOptions);
res.json({ code: 1, data: { ...authenticated, verified: true } });
}));
app.get('/api/connect/state', route(async (req, res) => {
const current = req.connectState.authenticated;
if (!current) return replyError(res, 401, 'app sign-in is not complete; connect the wallet first');
const platformState = await nexus.connect.getSession(current.sessionId);
if (!platformState.verified || platformState.revoked || !['connected', 'disconnected'].includes(platformState.status)) {
sessions.delete(req.connectToken);
res.clearCookie('cs_doc_session', { httpOnly: true, sameSite: 'strict', secure: site.protocol === 'https:', path: '/' });
return replyError(res, 401, 'the platform session is no longer valid; reconnect the wallet');
}
res.json({ code: 1, data: platformState });
}));
app.post('/api/connect/logout', route(async (req, res) => {
const current = req.connectState.authenticated;
sessions.delete(req.connectToken);
res.clearCookie('cs_doc_session', { httpOnly: true, sameSite: 'strict', secure: site.protocol === 'https:', path: '/' });
if (current) await nexus.connect.revokeSession(current.sessionId, 'user signed out of the app');
res.json({ code: 1, data: { loggedOut: true } });
}));
app.use((error, req, res, next) => {
const status = Number.isInteger(error.httpStatus) && error.httpStatus >= 400 && error.httpStatus <= 599 ? error.httpStatus : 500;
replyError(res, status, status >= 500 ? 'the connect service is temporarily unavailable; try again shortly' : 'connect verification failed; recheck the wallet and the original challenge');
});
return app;
}
// listen only when this file is run directly (node server.mjs).
// no server starts when another module imports it: an exported module must not have import-time side effects,
// otherwise any import would bind port 3000 and the doc example execution check would fail.
if (process.argv && process.argv[1] && process.argv[1].endsWith('server.mjs')) {
createConnectApp().listen(3000, '127.0.0.1', () => console.log('connect example listening; open it from the configured app origin'));
}
import { createWalletConnectPairing } from '@chainshield/nexus-sdk/browser';
// renderQr receives the real wc: URI and renders it with your own QR component; the SDK does not return qrSvg.
// signIn is the signInWithProvider above; metadata must use your app's real origin.
export async function signInWithWalletConnect({ projectId, metadata, renderQr, signIn, providerFactory, signal }) {
if (typeof renderQr !== 'function' || typeof signIn !== 'function') throw new Error('provide a QR renderer and a sign-in handler');
const pairing = await createWalletConnectPairing({
projectId, metadata, chains: [1], timeoutMs: 120000, providerFactory, signal
});
try {
if (pairing.uri) await renderQr(pairing.uri);
// showing the QR code does not mean connected; only request a signature after approval resolves.
const connection = await pairing.approval;
const result = await signIn({ ethereum: connection.provider });
return { ...result, disconnect: connection.disconnect };
} catch (error) {
await pairing.cancel();
throw error;
}
}
// projectId is the public WalletConnect relay project ID; never put a billing API key here.
// cancel scanning through the AbortController signal; call the returned disconnect on sign-out.
Session API
| Method | Path | Description | Authentication |
|---|---|---|---|
| POST | /api/v1/connect/session | Create a connection session — returns nonce, domain, chain, and expiry | Server key |
| GET | /api/v1/connect/session/:id | Get session status (pending / authenticated / revoked / expired) | Server key |
| POST | /api/v1/connect/siwe/verify | Verify a SIWE / SIWX signature and establish a session | Server key |
| POST | /api/v1/connect/session/:id/revoke | Revoke a platform session. The app should also clear its own sign-in state. | Server key |
| GET | /api/v1/connect/analytics | Connection analytics (success rate, latency, wallet distribution) | Server key |
SIWE verification checklist
ChainBridge Connect checks each item below independently. Any mismatch is rejected with a clear reason.
- Domain
- Must match the domain the challenge was created for. Pin the trusted domain server-side and never trust the browser.
- Origin
- Your server checks the request origin and pins the challenge URI. The signed message must match the text ChainBridge stored.
- Chain ID
- Must equal the chain declared at session creation. Cross-chain replay is rejected.
- nonce
- Single use — invalidated the moment verification succeeds. Reuse returns 400707.
- expiry
- The nonce lasts 10 minutes by default (expired: 400708). Sessions last 24 hours (expired: 400702). Always trust the create response.
- Signature
- Recover the signer address under EIP-191 / EIP-4361 and compare it byte for byte with the claimed address
Common error codes
| Error codes | Meaning | What to do |
|---|---|---|
| 400704 / 400705 / 400706 | Domain, origin, or chain mismatch | Use the configured app origin and the original message from this attempt |
| 400707 / 400708 | Nonce already used or expired | Create a new challenge; never replay an old signature |
| 400709 | Signature verification failed | Confirm the signer address matches the message. Never send a private key. |
| 400701 / 400702 / 400703 | Session missing, expired, or revoked | Create a new connection and clear your app's own sign-in state |
| 400013 | Rate limit exceeded | Wait for the interval in the rate-limit response. Don't resend the signature automatically. |
| 400007 | Insufficient permissions | Check that the server key has Connect product permissions |
| 500001 / 500002 / 500006 | Internal or upstream service error | Keep the request ID and contact support |
Integration details
No.cs_live_xxx form keys are account credentials and belong on your server only. QR pairing uses a public WalletConnect project ID. Every metered API call is made by your server and bound to the original challenge for the current app session.
Yes. The SDK's connect and sign steps rely only on the browser wallet object and run after client hydration. The server just creates sessions and verifies signatures — it needs no browser API.
If SDK initialization fails it returns a clear error. Your app can then show a specific message and keep a fallback connect flow.
WalletConnect pairing times out after 120 seconds by default and can be raised to 300. Pairing is timed separately from the server nonce lifetime. Nonces last 10 minutes and platform sessions 24 hours today — trust the create response. A used nonce returns 400707, an expired one 400708.
Can't find the endpoint you need?
Search endpoints, error codes, and setup steps by keyword.
Ready to integrate?
Create a project and API key in the console, then run one end-to-end test.