Authentication
Overview
Bakkt supports two authentication methods for user sessions:
- SIWE (Sign In With Ethereum) — Web3-native authentication via a wallet signature.
- 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.
| Method | Endpoints | Best for |
|---|---|---|
| SIWE | POST /auth/login | Users who already have a Web3 wallet |
| Email OTP | POST /auth/login → POST /auth/otp/ | Mainstream and corporate users |
Before you begin
- Base URLs: sandbox
https://sandbox.api.bakkt.com, productionhttps://api.bakkt.com. All examples on this page use sandbox.- API key: every call needs your key in the
Authorizationheader — the raw value, noBearerprefix.- User must exist: create the user with
POST /userbefore they can sign in.
Method 1: SIWE (Sign In With Ethereum)
Web3-native authentication using wallet signatures.
How SIWE Works
- User connects their wallet (MetaMask, WalletConnect, etc.)
- Your app generates a SIWE message
- User signs the message with their wallet
- Your app sends message + signature to Bakkt
- 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_IDExample 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
user-uuid insteadFor 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_UUIDThe general rule:
| Endpoint type | Required 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
domainto 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_addressstored 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
httpOnlycookies issued by your own backend over client-readable storage likelocalStorageorsessionStorage. - 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
| Feature | SIWE | Email OTP |
|---|---|---|
| User experience | One click once the wallet is connected | Email round-trip (trigger → code → submit) |
| Security model | Cryptographic signature | Time-limited code |
| Dependencies | Web3 wallet | Just an email address |
| Best for | Digital assets-native users | Mainstream and corporate users |
| Mobile | Works with mobile wallets (WalletConnect) | Works on any device |
You can offer both — let the user pick at sign-in time.
Troubleshooting
SIWE
| Symptom | Likely cause | What to check |
|---|---|---|
401 Unauthorized after submitting | Signature doesn't verify against the message | The exact string returned by prepareMessage() must be the one that was signed — no trimming, no re-encoding. |
401 Unauthorized with a valid signature | Signing address doesn't match the user | Confirm target_address on the user (GET /user) matches the wallet that signed. |
| Wallet rejects the prompt | Expired or malformed message | Generate a fresh message immediately before requesting the signature. |
| Wrong network in wallet UI | chainId mismatch | Use the chain ID that matches the wallet's currently selected network. |
Email OTP
| Symptom | Likely cause | What to check |
|---|---|---|
| Code never arrives | Email delivery / wrong address | Check spam, then verify email on the user with GET /user. |
401 Unauthorized on submit | Wrong or expired code | Trigger a fresh code with another POST /auth/login. |
Repeated 429 / generic failures | Too many trigger attempts | Back off and retry; implement client-side throttling. |
404 / user not found | Bad user_uuid | Confirm the user was actually created with POST /user. |
Security & credential handling
DEVELOPER OBLIGATION: CREDENTIAL PROTECTIONAPI 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
- User management — create users before they can authenticate.
- Corporate management — link users to corporate entities and assign roles.
- KYC & KYB — verification flows that follow first sign-in.
POST /auth/login— full request and response reference.POST /auth/otp/— OTP exchange reference.- Full API reference — every endpoint, schema, and error code.
Updated 1 day ago
