Authentication

Overview

Bakkt supports two authentication methods for user sessions:

  1. SIWE (Sign In With Ethereum) — Web3-native authentication via a wallet signature.
  2. Email OTP — Traditional one-time password sent via email.

Both methods exchange credentials for a bakkt_session_id, which you then send in the bakkt-session-id header on subsequent calls.

MethodEndpointsBest for
SIWEPOST /auth/loginUsers who already have a Web3 wallet
Email OTPPOST /auth/loginPOST /auth/otp/Mainstream and corporate users

Before you begin

  • Base URLs: sandbox https://sandbox.api.bakkt.com, production https://api.bakkt.com. All examples on this page use sandbox.
  • API key: every call needs your key in the Authorization header — the raw value, no Bearer prefix.
  • User must exist: create the user with POST /user before they can sign in.

Method 1: SIWE (Sign In With Ethereum)

Web3-native authentication using wallet signatures.

How SIWE Works

  1. User connects their wallet (MetaMask, WalletConnect, etc.)
  2. Your app generates a SIWE message
  3. User signs the message with their wallet
  4. Your app sends message + signature to Bakkt
  5. Bakkt verifies signature and returns session

Step 1: Generate SIWE Message

Use a library like siwe to generate the message:

import { SiweMessage } from 'siwe';

const message = new SiweMessage({
  domain: 'your-app.com',
  address: userWalletAddress,
  statement: 'Sign in to Bakkt',
  uri: 'https://your-app.com/auth/login',
  version: '1',
  chainId: 1,
  nonce: crypto.randomUUID().replace(/-/g, ''),
  issuedAt: new Date().toISOString(),
  expirationTime: new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString()
});

const messageString = message.prepareMessage();

Step 2: User Signs Message

// Request signature from user's wallet
const signature = await window.ethereum.request({
  method: 'personal_sign',
  params: [messageString, userWalletAddress]
});

Step 3: Authenticate with Bakkt

Send the message and signature to POST /auth/login:

const response = await fetch('https://sandbox.api.bakkt.com/auth/login', {
  method: 'POST',
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    message: messageString,
    signature: signature
  })
});

const { bakkt_session_id, user_uuid } = await response.json();

sessionStore.set(bakkt_session_id);

SIWE Example

import { SiweMessage } from 'siwe';
import { useAccount, useSignMessage } from 'wagmi';

function SignInButton() {
  const { address } = useAccount();
  const { signMessageAsync } = useSignMessage();

  const handleSignIn = async () => {
    // 1. Create SIWE message
    const message = new SiweMessage({
      domain: window.location.host,
      address,
      statement: 'Sign in to Bakkt',
      uri: window.location.origin,
      version: '1',
      chainId: 1,
      nonce: crypto.randomUUID().replace(/-/g, '')
    });

    // 2. Sign message
    const signature = await signMessageAsync({
      message: message.prepareMessage()
    });

    // 3. Authenticate
    const session = await bakkt.siweLogin(message.prepareMessage(), signature);

    // 4. Store session
    setSession(session.bakkt_session_id);
  };

  return <button onClick={handleSignIn}>Sign In with Ethereum</button>;
}
import { ethers } from 'ethers'; // ethers v6

async function signInWithEthereum() {
  const provider = new ethers.BrowserProvider(window.ethereum);
  await provider.send('eth_requestAccounts', []);
  const signer = await provider.getSigner();
  const address = await signer.getAddress();

  const nonce = crypto.randomUUID().replace(/-/g, '');
  const issuedAt = new Date().toISOString();
  const expirationTime = new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString();

  const message = `${window.location.host} wants you to sign in with your Ethereum account:
${address}

Sign in to Bakkt

URI: ${window.location.origin}
Version: 1
Chain ID: 1
Nonce: ${nonce}
Issued At: ${issuedAt}
Expiration Time: ${expirationTime}`;

  const signature = await signer.signMessage(message);

  const response = await fetch('https://sandbox.api.bakkt.com/auth/login', {
    method: 'POST',
    headers: {
      'Authorization': 'YOUR_API_KEY',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ message, signature })
  });

  return await response.json(); // { bakkt_session_id, user_uuid }
}

Important: the SIWE domain (the first line of the message) MUST be your app's host — the surface the user is consenting to sign in to. Wallets surface this string verbatim, and using the API host instead is a phishing red-flag for end users.

Method 2: Email OTP

Traditional one-time password sent via email.

Branded OTP emails. By default, OTP emails are sent from Bakkt's standard sender with Bakkt-branded content. You can customise the sender name, sender email, and email body/template so the OTP appears to come from your product rather than from Bakkt. This is configured out-of-band as part of your merchant contract — request it via your Bakkt account manager when signing, and the configuration is applied to your private sandbox and production environments before go-live. There is no self-service API for this today.

Step 1: Trigger OTP

Call POST /auth/login with just the user_uuid. Bakkt sends the code to the email associated with that user and responds 200 with an empty body.

await fetch('https://sandbox.api.bakkt.com/auth/login', {
  method: 'POST',
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    user_uuid: 'user-uuid-here'
  })
});

Step 2: Submit OTP

The user receives the code via email and submits it to POST /auth/otp/:

const response = await fetch('https://sandbox.api.bakkt.com/auth/otp/', {
  method: 'POST',
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    user_uuid: 'user-uuid-here',
    one_time_password: '123456'
  })
});

const { bakkt_session_id, user_uuid } = await response.json();

sessionStore.set(bakkt_session_id);

Full OTP example

async function loginWithOTP(userUuid) {
  // 1. Trigger OTP delivery
  await fetch('https://sandbox.api.bakkt.com/auth/login', {
    method: 'POST',
    headers: {
      'Authorization': API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ user_uuid: userUuid })
  });

  // 2. Collect the code from the user (UI is up to you)
  const otp = await promptUser('Enter the code we just emailed you:');

  // 3. Exchange the code for a session
  const response = await fetch('https://sandbox.api.bakkt.com/auth/otp/', {
    method: 'POST',
    headers: {
      'Authorization': API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      user_uuid: userUuid,
      one_time_password: otp
    })
  });

  const { bakkt_session_id } = await response.json();
  return bakkt_session_id;
}

Using the session

Once authenticated, send the session ID on every subsequent call:

Authorization: YOUR_API_KEY
bakkt-session-id: SESSION_ID

Example with fetch:

const response = await fetch('https://sandbox.api.bakkt.com/user', {
  method: 'GET',
  headers: {
    'Authorization': 'YOUR_API_KEY',
    'bakkt-session-id': sessionId
  }
});

When to use user-uuid instead

For server-to-server flows where you don't have an end-user session in hand, most user-scoped read endpoints in onboarding accept a user-uuid header instead of bakkt-session-id:

Authorization: YOUR_API_KEY
user-uuid: USER_UUID

The general rule:

Endpoint typeRequired auth
Merchant-scoped or informational (e.g. GET /supported/countries, GET /merchant/*)API key only
User-scoped read (most onboarding GET endpoints)API key + bakkt-session-id or user-uuid
User-scoped write (creating, updating, or deleting user data)API key + bakkt-session-id

POST /user/kyc/applicant/share-token also accepts user-uuid because it's typically performed by your backend on the user's behalf.

When in doubt, the API reference lists the accepted security schemes for every endpoint.

Session management

Bakkt sessions are opaque tokens with a server-defined lifetime; treat them as short-lived. There is no refresh-token endpoint and no /auth/logout — to "log out" a user, discard the session ID locally and re-authenticate on the next interaction.

Handling expiration

When a session is no longer valid, the API responds 401 Unauthorized. Re-authenticate (SIWE or OTP) and retry:

async function authedFetch(endpoint, options = {}) {
  const sessionId = sessionStore.get();

  const response = await fetch(endpoint, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': API_KEY,
      'bakkt-session-id': sessionId
    }
  });

  if (response.status === 401) {
    const newSessionId = await reAuthenticate();
    sessionStore.set(newSessionId);
    return authedFetch(endpoint, options);
  }

  return response;
}

Best practice: prompt the user to re-authenticate proactively — for example, before submitting a long form — rather than letting a 401 interrupt a critical action.

Security best practices

SIWE

  • Domain match. Set the SIWE domain to your app's host (the surface the user sees), not the Bakkt API host.
  • Cryptographic nonce. Use crypto.randomUUID() or another CSPRNG and never reuse a nonce.
  • Reasonable expiration. 2–4 hours balances UX with replay protection.
  • Server-side verification. Bakkt verifies the signature; you don't need to (and shouldn't) re-implement it.
  • Address binding. The signing wallet address must match the target_address stored on the user.

Email OTP

  • Throttle on your side too. Bakkt enforces server-side limits, but rate-limit user-initiated OTP triggers in your client/backend to avoid noisy email delivery.
  • Escalate on repeated failures. After a few wrong codes, surface friction (re-send, support contact) rather than letting attempts continue indefinitely.
  • Never log OTPs. Keep them out of access logs, error trackers, and analytics payloads.
  • Trust the channel. Ensure the email account on file is one the user controls; treat email change events as security-sensitive.

Session storage

  • Prefer httpOnly cookies issued by your own backend over client-readable storage like localStorage or sessionStorage.
  • HTTPS only, in every environment including local development behind a tunnel.
  • Discard on sign-out. There is no server-side logout endpoint; clear the session ID from your store so it can't be reused.
  • Shorten effective lifetime for high-risk operations by re-prompting for SIWE/OTP before they happen.
  • Monitor. Log authentication attempts (success and failure) and alert on unusual patterns.

Choosing a method

FeatureSIWEEmail OTP
User experienceOne click once the wallet is connectedEmail round-trip (trigger → code → submit)
Security modelCryptographic signatureTime-limited code
DependenciesWeb3 walletJust an email address
Best forDigital assets-native usersMainstream and corporate users
MobileWorks with mobile wallets (WalletConnect)Works on any device

You can offer both — let the user pick at sign-in time.

Troubleshooting

SIWE

SymptomLikely causeWhat to check
401 Unauthorized after submittingSignature doesn't verify against the messageThe exact string returned by prepareMessage() must be the one that was signed — no trimming, no re-encoding.
401 Unauthorized with a valid signatureSigning address doesn't match the userConfirm target_address on the user (GET /user) matches the wallet that signed.
Wallet rejects the promptExpired or malformed messageGenerate a fresh message immediately before requesting the signature.
Wrong network in wallet UIchainId mismatchUse the chain ID that matches the wallet's currently selected network.

Email OTP

SymptomLikely causeWhat to check
Code never arrivesEmail delivery / wrong addressCheck spam, then verify email on the user with GET /user.
401 Unauthorized on submitWrong or expired codeTrigger a fresh code with another POST /auth/login.
Repeated 429 / generic failuresToo many trigger attemptsBack off and retry; implement client-side throttling.
404 / user not foundBad user_uuidConfirm the user was actually created with POST /user.

Security & credential handling

🚧

DEVELOPER OBLIGATION: CREDENTIAL PROTECTION

API credentials and resulting session tokens must be stored securely on your backend servers. Exposing API keys or sensitive user session data in client-side code (frontend web or mobile applications) is strictly prohibited. By utilizing this API, you agree that Bakkt reserves the right to immediately revoke suspected compromised keys without notice, and you accept full financial and legal liability for any resulting breaches.

Next steps