Skip to content
Skip to content

Security

Balchemy is built around a wallet-first trust model. You authenticate with a cryptographic signature, not a password. This page explains how that works, what Balchemy can and cannot do with your wallet, and — for developers — the full technical architecture behind every security layer.


For Users

How authentication works

Balchemy uses Sign-In with Solana (SIWS) for Solana wallets and Sign-In with Ethereum (SIWE) for EVM-compatible wallets. Both follow the same flow: you sign a short text message in your wallet extension, and that signature proves you own the wallet address without transmitting your private key. Signing a message is categorically different from approving a transaction — no spending authority is granted.

When you connect your wallet, the backend generates a one-time cryptographic nonce tied to your address. Your wallet signs a message embedding that nonce, and Balchemy verifies the signature against your public key. Once verified, the nonce is discarded and cannot be reused, preventing replay attacks.

Step-by-step authentication flow:

  1. Click "Connect Wallet" in Studio or Explorer.
  2. Balchemy requests a fresh nonce from the server.
  3. Your wallet extension displays a human-readable message containing the nonce, your address, and a timestamp.
  4. You approve the signature — no funds move at any point.
  5. Balchemy verifies the cryptographic signature against your wallet's public key.
  6. A signed JWT is issued and stored in your browser session.
  7. All subsequent API calls use that JWT as a Bearer token.

Tip: Use a hardware wallet (Ledger, Trezor) for maximum security. Hardware wallets require physical button confirmation before signing anything, so malware on your computer cannot silently approve requests on your behalf.

Session management

Your Balchemy session is controlled by a JWT token issued at authentication time. The default session lifetime is 24 hours. When the token expires you will be prompted to reconnect your wallet and sign a new nonce — your bots, settings, and trading history are never affected by session expiry.

Session lifetimes vary by context:

  • Studio / app sessions: 24 hours by default (operator-configurable up to 7 days)
  • Widget sessions: 1 hour TTL — the embeddable chat widget requires re-authentication after this window
  • External agent JWTs: scope-dependent (see the Developer section for TTLs per scope)

Warning: Never share your JWT token. If you suspect it has been compromised, go to Studio Settings and revoke it. Revocation is immediate — the token is added to a distributed blocklist and will be rejected on every subsequent request even if it has not expired.

Wallet security

Balchemy's custodial wallet system encrypts all private keys at rest using AES-256-GCM with a data encryption key (DEK) wrapped by a master key encryption key (KEK). Private keys are never stored in plain text on Balchemy servers and never appear in logs or error responses.

Here is what Balchemy can and cannot do with connected wallets:

ActionBalchemy can doBalchemy cannot do
Authenticate youVerify a signed message against your public keyRead or access your private key
Execute tradesSubmit transactions from your custodial trading walletMove funds from external wallets you have not connected
Display balancesRead your on-chain balance via public RPCModify any on-chain state without your authorization
Revoke accessInvalidate your session JWTRetain access after you disconnect your wallet

You control the trading wallet your bots use. You fund it, and you can withdraw from it at any time through Studio. Balchemy only executes trades within the risk limits and strategy parameters you configure.

Recommended security practices:

  • Review your bot trade history in Studio regularly. Unexpected orders should be investigated immediately.
  • Configure risk limits on each bot — max trade size, daily loss limit, and approval thresholds — to prevent runaway strategies.
  • Enable step-up verification for sensitive operations such as withdrawals and API key rotation.
  • Disconnect your wallet when using shared or public computers. Clearing the browser session also invalidates the JWT.
  • Always verify you are on the correct domain (app.balchemy.ai) before signing any wallet message. Balchemy will never request a login signature on a third-party site.

Multi-factor authentication

MFA is available on Balchemy accounts. When enabled, sensitive operations — changing trading strategies, rotating API keys, modifying bot permissions — require a second verification step. Standard MFA sessions are valid for 30 minutes; setup-level operations (configuring MFA itself) have a shorter 5-minute validity window to reduce exposure.

You can enable and manage MFA in Studio Settings. If MFA is not enabled, authentication relies entirely on your wallet signature, which is already a strong cryptographic factor because your private key never leaves your wallet or hardware device.


For Developers

This section covers the full technical implementation of Balchemy's security stack. All details are sourced from the live codebase at balchemy-backend/src/core/auth/ and balchemy-backend/src/common/guards/.

JWT architecture

Balchemy uses ES256 (ECDSA P-256) asymmetric JWTs as the primary signing algorithm. The JwtKeyringService loads PEM-encoded keys from environment at startup:

  • JWT_PRIVATE_KEY_PEM — signs tokens on issuance
  • JWT_PUBLIC_KEY_PEM — verifies tokens on every authenticated request

Each token carries a kid (key ID) header field. During key rotation, the keyring maintains both currentKid and previousKid and attempts verification against both keys, allowing graceful rotation without forcing all active sessions to re-authenticate. A HS256 symmetric fallback exists for environments still migrating (JWT_SECRET, JWT_SECRET_PREVIOUS), but ES256 is the production standard.

JWT payload shape:

interface JwtPayload {
  userId: string;         // MongoDB ObjectId serialized as string
  walletAddress?: string; // Solana base58 or EVM checksummed address
  role: UserRole;         // 'user' | 'admin' | 'moderator' | 'developer' | 'guest'
  iat: number;            // Issued-at (Unix epoch seconds)
  exp: number;            // Expiry (Unix epoch seconds)
}

The JwtAuthGuard (Passport strategy jwt) extracts the token from Authorization: Bearer, verifies the signature and expiry, then normalizes the user onto request.auth.user and request.auth.userId for all downstream handlers. When a token is expired, the guard returns a distinct TOKEN_EXPIRED error code so clients can distinguish expiry from invalid tokens and prompt re-authentication cleanly.

External agent JWT scopes

External agents connecting via Hub receive scoped JWTs. Token lifetime is calibrated to the risk of each scope level — shorter lifetimes reduce the blast radius of any key compromise:

ScopeCapabilitiesTTL
readQuery bots, balances, history, market data3600 s (1 hour)
tradeSubmit orders, approve pending trades300 s (5 minutes)
manageRotate keys, update agent config, claim ownership60 s (1 minute)

manage scope is only available through the claim path and requires a step-up token. External agents must request fresh tokens rather than refreshing — there is no token refresh flow for agent JWTs by design. Agents that need long-running trade execution should re-authenticate on each trade cycle.

Step-up endpoints:

POST /api/nest/bots/:botId/mcp/step-up              (Studio)
POST /api/nest/agents/:agentId/control/mcp/step-up  (Hub)

Token blacklist: 3-layer revocation

Every authenticated request is checked against a three-layer revocation system before the payload is processed:

  1. In-memory cache: O(1) lookup within the current process. Populated on revocation. Flushed on service restart.
  2. Redis: Distributed revocation store. Tokens are written with a TTL equal to their remaining validity window. Revocation propagates to all backend replicas within milliseconds.
  3. MongoDB: Persistent record of all revocations. Acts as the audit trail and source of truth when Redis is unavailable (fail-safe behavior).

Any single layer can block a token independently. This means revocation survives service restarts, Redis flushes, and horizontal scaling events — a token revoked in MongoDB will be blocked even if the in-memory and Redis caches are cold.

CSRF protection

Balchemy uses timing-safe nonce comparison for CSRF prevention. Nonces are generated with crypto.randomBytes(32) and embedded in the authentication message shown to the user's wallet. The nonce is consumed and deleted from the user record on first successful use, making replay attacks structurally impossible: a second request using the same signed message will fail nonce validation regardless of token validity.

Core API endpoints require the Authorization: Bearer header rather than cookie-based sessions, which eliminates classical CSRF vectors for the primary API surface. The widget endpoint uses origin allowlisting as a secondary layer.

Inter-service authentication

All backend-to-backend communication — including calls from the NestJS backend to the Rust trading engine over gRPC — uses HMAC signing with INTER_SERVICE_HMAC_KEY. The InternalRpcAuthGuard validates the HMAC envelope on every inbound RPC call before the payload reaches any handler.

The guard extracts and propagates OpenTelemetry trace context from the envelope, then strips all internal transport metadata before forwarding to DTO validators:

  • _interServiceAuth — HMAC signature and source service identity
  • _interServiceMeta — correlation ID and routing metadata
  • _interServiceTrace — OpenTelemetry traceparent / tracestate

This design ensures internal service identity cannot be spoofed from external HTTP requests because the internal TCP transport is never exposed to the public network.

Rate limiting

Balchemy implements two complementary rate-limiting layers:

Redis INCR+EXPIRE (edge layer): Applied before authentication on public endpoints. Uses an atomic Redis increment with a sliding window TTL. The guard is fail-open by design: if Redis is unreachable, requests are allowed through rather than blocking traffic, preventing a Redis outage from causing an API outage. Emergency rate-limit guards provide a stricter fallback for high-abuse scenarios.

Per-user daily AI quota: Tracked in MongoDB on each user record (remainingRequests, aiLimitResetAt). The RateLimitGuard calls calculateUserLimits() to resolve each user's quota from global server-level constants. Quotas reset daily at UTC midnight. When exhausted, subsequent requests receive HTTP 429 with the reset timestamp. Monthly quota enforcement follows the same pattern on a separate counter field.

MFA implementation

The MfaGuard protects routes decorated with @RequireMFA() (standard operations) or @RequireMFASetup() (setup operations). For users with MFA enabled, the guard compares lastMfaVerifiedAt on the user record against the current time. If the gap exceeds the validity window, the guard rejects with MFA_VERIFICATION_REQUIRED.

Validity windows:

  • Standard operations (@RequireMFA): configurable via MFA_VALID_PERIOD env var, default 30 minutes
  • Setup operations (@RequireMFASetup): hardcoded 5 minutes

MFA secrets are stored encrypted via MfaSecretCryptoService. The guard is a no-op for users who have not enabled MFA — enabling is always opt-in.

API keys

API keys are managed through ApiKeyService with these security properties:

  • Format validation (/^[A-Za-z0-9_-]+$/, minimum 20 characters) runs before any database lookup, preventing timing attacks on malformed inputs.
  • Keys are returned to the user exactly once — at generation time. Only a hash is stored server-side.
  • rotatePrimaryApiKey atomically invalidates the previous key before issuing a new one.
  • Key permissions derive from the user's role field, not from the key record itself, ensuring role changes propagate immediately to all active keys.

Connection lost. Retrying...