Back to Blog
Featured

Bitcoin Authentication: Technical Integration Guide

B0ASE Team
|
20 min read
|15 January 2026
BitcoinAuthenticationSecurityWeb3TechnicalBSVCryptography

This guide will walk you through the complete process of integrating Bitcoin-based authentication into your application. We will cover everything from the underlying cryptographic principles to production deployment strategies. By the end of this guide, you will understand how to implement a secure, scalable authentication system that uses Bitcoin's proven cryptography instead of traditional passwords.

What You Will Learn

In this comprehensive guide, we will explore the cryptographic foundations that make Bitcoin authentication possible. You will learn how to design and implement the complete authentication flow, from challenge generation through token issuance. We will cover wallet integration for popular Bitcoin wallets, security best practices to protect your users, and deployment strategies for production environments.

The guide includes complete, working code examples in TypeScript that you can adapt for your own application. We will also examine common pitfalls and how to avoid them, ensuring your implementation is both secure and user-friendly.


Understanding the Cryptographic Foundation

Bitcoin authentication works by leveraging the same cryptographic principles that secure billions of dollars in cryptocurrency transactions. At its core, the system relies on asymmetric cryptography, specifically the Elliptic Curve Digital Signature Algorithm, commonly known as ECDSA. This mathematical framework allows users to prove they control a specific Bitcoin address without ever revealing their private key.

The Four Key Components

Every Bitcoin authentication system revolves around four fundamental cryptographic elements. First, there is the private key, which is a secret number known only to the user and must never be shared or transmitted over the network. Second, there is the public key, which is mathematically derived from the private key and can be safely shared with anyone. Third, there is the signature, which is a mathematical proof that the holder of the private key has signed a specific message. Finally, there is the address, which is a Base58Check encoded hash of the public key that serves as the user's identifier.

When a user authenticates using Bitcoin, they use their private key to create a digital signature of a challenge message. Your server can then verify this signature using the user's public key, proving that the person authenticating actually controls the private key associated with that Bitcoin address. The beauty of this system is that the private key never leaves the user's wallet, making it virtually impossible for an attacker to steal their credentials through traditional phishing or man-in-the-middle attacks.

Signature Standards You Need to Know

There are two primary signature formats used in Bitcoin authentication systems today. The first is Bitcoin Signed Message, or BSM, which is the traditional format that has been used for Bitcoin message signing since the early days of the protocol. This format produces signatures that are Base64 encoded and look something like this:

{
  address: "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
  message: "Login to myapp.com at 2026-01-15T10:30:00Z",
  signature: "IFqUo3h...Rz5qk="
}

The second format is BRC-77, which is a modern standard designed specifically for Web3 applications. BRC-77 provides better structure and metadata handling, making it more suitable for complex authentication flows. A BRC-77 payload typically looks like this:

{
  identityKey: "03a1b2c3...",
  signaturePayload: {
    message: "Login challenge",
    timestamp: 1705318200000
  },
  signature: "3045022100..."
}

For new implementations, we recommend using BRC-77 because it provides better standardization and tooling support. However, if you need to support legacy systems or maintain backwards compatibility with existing Bitcoin wallets, you should implement BSM as well.


How the Authentication Flow Works

The Bitcoin authentication process involves a carefully orchestrated exchange between three parties: the client application running in the user's browser, your server, and the user's Bitcoin wallet. Understanding this flow is crucial for implementing a secure system.

The Seven-Step Authentication Dance

The process begins when the client application requests an authentication challenge from your server. This challenge is a unique, time-limited message that will be signed by the user's wallet. The server generates this challenge with specific security properties and stores it temporarily in a fast cache like Redis.

Next, the server sends the challenge back to the client application. The client then presents this challenge to the user's Bitcoin wallet, which might be HandCash, Yours Wallet, or any other compatible wallet application. The wallet shows the user what they are being asked to sign and requests their approval.

Once the user approves, their wallet uses their private key to create a cryptographic signature of the challenge message. This signature is then returned to the client application. The client immediately submits both the challenge and the signature back to your server for verification.

Your server now performs the critical verification step. It checks that the challenge matches what was originally generated, verifies that the signature is mathematically valid for that challenge and address, confirms that the challenge has not expired, and ensures this challenge has not been used before to prevent replay attacks.

If all verification checks pass, your server issues an authentication token (typically a JWT) and creates or updates the user's account record in your database. The user is now authenticated and can access protected resources.

Here is a visual representation of this flow:

┌─────────┐                  ┌─────────┐                  ┌─────────┐
│ Client  │                  │  Server │                  │ Wallet  │
└────┬────┘                  └────┬────┘                  └────┬────┘
     │                            │                            │
     │  1. Request challenge      │                            │
     │ ─────────────────────────> │                            │
     │                            │                            │
     │  2. Return challenge       │                            │
     │ <───────────────────────── │                            │
     │                            │                            │
     │  3. Sign challenge         │                            │
     │ ────────────────────────────────────────────────────> │
     │                            │                            │
     │  4. Return signature       │                            │
     │ <──────────────────────────────────────────────────── │
     │                            │                            │
     │  5. Submit signature       │                            │
     │ ─────────────────────────> │                            │
     │                            │                            │
     │                            │  6. Verify signature       │
     │                            │ ──────────────────────┐   │
     │                            │                       │   │
     │                            │ <─────────────────────┘   │
     │                            │                            │
     │  7. Return auth token      │                            │
     │ <───────────────────────── │                            │

Step One: Generating a Secure Challenge

The challenge generation step is where security begins. Your server must create a message that is unique, time-bound, and tied to both your application and the specific user attempting to authenticate.

Here is how you implement secure challenge generation:

// server/auth/challenge.ts
export async function generateChallenge(address: string): Promise<string> {
  const timestamp = Date.now();
  const nonce = crypto.randomBytes(16).toString('hex');

  const challenge = {
    domain: 'app.example.com',
    address: address,
    statement: 'Sign in to Example App',
    nonce: nonce,
    timestamp: timestamp,
    expiresAt: timestamp + 5 * 60 * 1000  // Expires in 5 minutes
  };

  // Store the challenge in Redis with a five-minute time-to-live
  await redis.setex(
    `challenge:${address}:${nonce}`,
    300,
    JSON.stringify(challenge)
  );

  return JSON.stringify(challenge);
}

Every challenge must include several critical elements. The nonce must be a cryptographically random value that ensures each challenge is unique, even if the same user authenticates multiple times in quick succession. The timestamp and expiration time work together to create a limited validity window, preventing attackers from reusing old challenges. The domain binding prevents an attacker from using a challenge generated for one application on a different application. Finally, including the user's address ensures the challenge is specific to the authenticating user.

Step Two: Requesting a Signature from the Wallet

Once your client application receives the challenge from the server, it must present it to the user's Bitcoin wallet for signing. This step requires careful error handling because users might not have a wallet installed, might deny the signature request, or might experience network issues.

Here is a complete client implementation:

// client/auth/bitcoin-auth.ts
import { HandCashConnect } from '@handcash/handcash-connect';

export class BitcoinAuthClient {
  private handcash: HandCashConnect;

  constructor(appId: string) {
    this.handcash = new HandCashConnect({ appId });
  }

  async authenticate(): Promise<AuthResponse> {
    // First, we retrieve the user's wallet address from their connected wallet
    const account = await this.handcash.getAccount();
    const address = account.publicProfile.paymail;

    // Next, we request a fresh challenge from the server
    const challengeResponse = await fetch('/api/auth/challenge', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ address })
    });

    const { challenge } = await challengeResponse.json();

    // We then ask the wallet to sign the challenge
    const signature = await this.signMessage(challenge);

    // Finally, we submit the signature back to the server for verification
    const authResponse = await fetch('/api/auth/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        address,
        challenge,
        signature
      })
    });

    return authResponse.json();
  }

  private async signMessage(message: string): Promise<string> {
    const account = await this.handcash.getAccount();
    return account.profile.sign({ message });
  }
}

The wallet's signing process happens entirely on the client side, within the wallet application itself. The private key never leaves the wallet and is never transmitted to your application. This is what makes Bitcoin authentication fundamentally more secure than password-based systems where credentials must be sent to the server.

Step Three: Verifying the Signature on the Server

When your server receives the signature, it must perform a series of verification steps to ensure the authentication request is legitimate. This is the most security-critical part of the entire flow.

Here is the complete verification implementation:

// server/auth/verify.ts
import * as bsv from 'bsv';

export async function verifySignature(
  address: string,
  message: string,
  signature: string
): Promise<boolean> {
  try {
    // First, we retrieve the stored challenge from our cache
    const storedChallenge = await redis.get(`challenge:${address}`);
    if (!storedChallenge) {
      throw new Error('Challenge expired or not found');
    }

    // We verify that the challenge submitted matches what we generated
    if (message !== storedChallenge) {
      throw new Error('Challenge mismatch');
    }

    // This is where the cryptographic magic happens - we verify the signature
    const isValid = bsv.Message.verify(message, address, signature);

    if (!isValid) {
      throw new Error('Invalid signature');
    }

    // We check that the challenge has not expired
    const challenge = JSON.parse(message);
    if (Date.now() > challenge.expiresAt) {
      throw new Error('Challenge expired');
    }

    // Finally, we delete the used challenge to prevent replay attacks
    await redis.del(`challenge:${address}`);

    return true;
  } catch (error) {
    console.error('Signature verification failed:', error);
    return false;
  }
}

The cryptographic verification step uses the BSV library to perform the mathematical proof that the signature was created by the private key corresponding to the claimed address. This is a one-way mathematical operation that proves ownership without ever revealing the private key itself.

Step Four: Issuing an Authentication Token

After successful signature verification, your server needs to issue an authentication token that the client can use for subsequent requests. We typically use JSON Web Tokens (JWTs) for this purpose because they are stateless and can carry user information securely.

Here is how to implement secure token issuance:

// server/auth/token.ts
import jwt from 'jsonwebtoken';

export async function issueAuthToken(address: string): Promise<string> {
  // First, we look up the user in our database or create a new record
  let user = await db.user.findUnique({ where: { address } });

  if (!user) {
    user = await db.user.create({
      data: {
        address,
        authMethod: 'BITCOIN',
        createdAt: new Date()
      }
    });
  }

  // We update the last login timestamp for security monitoring
  await db.user.update({
    where: { id: user.id },
    data: { lastLoginAt: new Date() }
  });

  // Finally, we generate a JWT with a seven-day expiration
  const token = jwt.sign(
    {
      userId: user.id,
      address: user.address,
      authMethod: 'BITCOIN'
    },
    process.env.JWT_SECRET!,
    { expiresIn: '7d' }
  );

  return token;
}

The JWT contains the minimum information needed to identify the user on subsequent requests. We include the authentication method in the token so that your application can handle hybrid authentication scenarios where some users authenticate with Bitcoin while others use traditional methods.


Building the Backend API

Now that you understand the authentication flow, let's implement the actual API endpoints that will handle challenge generation and signature verification. We will use Next.js App Router for these examples, but the principles apply to any backend framework.

Creating the Challenge Endpoint

The challenge endpoint receives a Bitcoin address and returns a unique challenge for that address. This is a public endpoint that does not require authentication.

// app/api/auth/challenge/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { generateChallenge } from '@/lib/auth/bitcoin';

export async function POST(req: NextRequest) {
  try {
    const { address } = await req.json();

    // We validate that an address was provided
    if (!address) {
      return NextResponse.json(
        { error: 'Address required' },
        { status: 400 }
      );
    }

    // We generate and return a fresh challenge
    const challenge = await generateChallenge(address);

    return NextResponse.json({ challenge });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to generate challenge' },
      { status: 500 }
    );
  }
}

This endpoint should be rate-limited to prevent abuse. We will cover rate limiting in the security section later in this guide.

Creating the Verification Endpoint

The verification endpoint receives the challenge and signature, verifies them, and issues an authentication token if verification succeeds.

// app/api/auth/verify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifySignature, issueAuthToken } from '@/lib/auth/bitcoin';

export async function POST(req: NextRequest) {
  try {
    const { address, challenge, signature } = await req.json();

    // We verify the signature cryptographically
    const isValid = await verifySignature(address, challenge, signature);

    if (!isValid) {
      return NextResponse.json(
        { error: 'Invalid signature' },
        { status: 401 }
      );
    }

    // If verification succeeds, we issue an authentication token
    const token = await issueAuthToken(address);

    return NextResponse.json({
      success: true,
      token,
      address
    });
  } catch (error) {
    console.error('Verification error:', error);
    return NextResponse.json(
      { error: 'Authentication failed' },
      { status: 500 }
    );
  }
}

The verification endpoint returns a 401 Unauthorized status if the signature is invalid, and a 200 OK with the authentication token if verification succeeds. This follows standard HTTP authentication patterns and makes it easy for clients to handle authentication failures.

Building a User-Friendly Sign-In Button

On the frontend, you need a component that orchestrates the entire authentication flow. Here is a complete React component that handles all the states and error conditions:

// components/auth/bitcoin-sign-in-button.tsx
'use client';

import { useState } from 'react';
import { BitcoinAuthClient } from '@/lib/auth/bitcoin-auth-client';

export function BitcoinSignInButton() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleSignIn = async () => {
    try {
      setLoading(true);
      setError(null);

      // We initialize the authentication client with our app ID
      const authClient = new BitcoinAuthClient(
        process.env.NEXT_PUBLIC_HANDCASH_APP_ID!
      );

      // We execute the complete authentication flow
      const { token, address } = await authClient.authenticate();

      // We store the token in local storage for subsequent requests
      localStorage.setItem('auth_token', token);

      // Finally, we redirect the user to their dashboard
      window.location.href = '/dashboard';
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Authentication failed');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <button
        onClick={handleSignIn}
        disabled={loading}
        className="px-6 py-3 bg-orange-500 text-white font-semibold hover:bg-orange-600 transition-colors disabled:opacity-50"
      >
        {loading ? 'Connecting to wallet...' : 'Sign in with Bitcoin'}
      </button>
      {error && (
        <p className="mt-2 text-sm text-red-500">{error}</p>
      )}
    </div>
  );
}

This component provides clear feedback to the user throughout the authentication process. The button text changes while connecting to the wallet, and any errors are displayed prominently below the button.

Designing the Database Schema

Your database needs to store user records and authentication sessions. Here is a recommended PostgreSQL schema that supports Bitcoin authentication:

-- The users table stores basic user information
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  address VARCHAR(255) UNIQUE NOT NULL,
  auth_method VARCHAR(50) NOT NULL,
  created_at TIMESTAMP DEFAULT NOW(),
  last_login_at TIMESTAMP,
  metadata JSONB
);

-- The sessions table tracks active authentication sessions
CREATE TABLE sessions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  token_hash VARCHAR(255) UNIQUE NOT NULL,
  expires_at TIMESTAMP NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

-- The auth_logs table helps with security monitoring
CREATE TABLE auth_logs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id),
  address VARCHAR(255),
  action VARCHAR(50),
  success BOOLEAN,
  ip_address INET,
  user_agent TEXT,
  created_at TIMESTAMP DEFAULT NOW()
);

The metadata column on the users table allows you to store additional information about users in a flexible JSON format. This is useful for storing wallet-specific data or user preferences.


Integrating with Bitcoin Wallets

Different Bitcoin wallets have different integration approaches. Let's look at how to integrate with the most popular wallets in the Bitcoin SV ecosystem.

Working with HandCash

HandCash is one of the most popular Bitcoin wallets, offering excellent developer tools and a user-friendly interface. To integrate HandCash, you first need to install their SDK:

npm install @handcash/handcash-connect

Here is a complete HandCash integration class:

// lib/wallets/handcash.ts
import { HandCashConnect } from '@handcash/handcash-connect';

export class HandCashAuth {
  private handcash: HandCashConnect;

  constructor(appId: string, appSecret?: string) {
    // We initialize the HandCash connector with our application credentials
    this.handcash = new HandCashConnect({
      appId,
      appSecret,
      network: process.env.NODE_ENV === 'production' ? 'mainnet' : 'testnet'
    });
  }

  async connect(): Promise<HandCashAccount> {
    // This retrieves the user's account information from HandCash
    const account = await this.handcash.getAccount();
    return account;
  }

  async signMessage(message: string): Promise<string> {
    // This requests a signature from the user's HandCash wallet
    const account = await this.handcash.getAccount();
    return account.profile.sign({ message });
  }

  async getProfile() {
    // This retrieves the user's public profile information
    const account = await this.handcash.getAccount();
    return account.profile.getCurrentProfile();
  }
}

The HandCash SDK handles all the complexity of wallet communication, popup windows, and user approval flows. You simply call these methods and the SDK takes care of the rest.

Working with Yours Wallet

Yours Wallet takes a different approach, using a browser extension that injects a global object into the page. Here is how to integrate with Yours Wallet:

// lib/wallets/yours.ts
export class YoursWalletAuth {
  async connect(): Promise<void> {
    // We first check that the wallet extension is installed
    if (typeof window === 'undefined' || !window.yours) {
      throw new Error('Yours Wallet not installed');
    }

    // We request connection permission from the user
    await window.yours.connect();
  }

  async signMessage(message: string): Promise<string> {
    // This requests a signature from the Yours Wallet extension
    const signature = await window.yours.signMessage({ message });
    return signature;
  }

  async getAddress(): Promise<string> {
    // This retrieves the user's Bitcoin address from the wallet
    const address = await window.yours.getAddress();
    return address;
  }
}

The Yours Wallet integration is simpler because the wallet extension handles the UI directly in the browser. Your application just calls the API methods provided by the injected object.

Supporting Multiple Wallets

In a production application, you should support multiple wallet providers to give users choice. Here is how to build a wallet manager that abstracts the differences between wallets:

// lib/wallets/wallet-manager.ts
export class WalletManager {
  private wallets: Map<WalletType, WalletAdapter>;

  constructor() {
    // We register all supported wallet adapters
    this.wallets = new Map([
      ['handcash', new HandCashAdapter()],
      ['yours', new YoursAdapter()],
      ['relayx', new RelayXAdapter()]
    ]);
  }

  async connect(walletType: WalletType): Promise<WalletConnection> {
    // We retrieve the appropriate wallet adapter
    const wallet = this.wallets.get(walletType);
    if (!wallet) {
      throw new Error(`Wallet ${walletType} not supported`);
    }

    // We delegate to the wallet-specific implementation
    return wallet.connect();
  }

  async signMessage(
    walletType: WalletType,
    message: string
  ): Promise<string> {
    // We retrieve the appropriate wallet adapter
    const wallet = this.wallets.get(walletType);
    if (!wallet) {
      throw new Error(`Wallet ${walletType} not supported`);
    }

    // We delegate to the wallet-specific implementation
    return wallet.signMessage(message);
  }
}

This wallet manager pattern allows you to add support for new wallets without changing your authentication code. You simply implement a new adapter that conforms to the WalletAdapter interface.


Implementing Security Best Practices

Security is paramount in any authentication system. Let's explore the specific security measures you must implement to protect your Bitcoin authentication system.

Building Secure Challenges

Every challenge you generate must include specific elements that prevent various attack vectors. Here is what a secure challenge looks like:

interface SecureChallenge {
  domain: string;      // Prevents cross-domain attacks
  nonce: string;       // Ensures uniqueness
  timestamp: number;   // Marks when challenge was created
  expiresAt: number;   // Sets expiration deadline
  address: string;     // Binds to specific user
  statement: string;   // Explains to user what they are signing
}

The domain field prevents an attacker from using a challenge generated for your application on their malicious site. The nonce ensures that even if a user authenticates multiple times in rapid succession, each authentication uses a unique challenge. The timestamp and expiration work together to limit the window of opportunity for attacks. The address binds the challenge to a specific user, preventing one user's challenge from being used by another user. The statement provides a human-readable explanation of what the user is signing, which helps prevent social engineering attacks.

Preventing Replay Attacks

A replay attack occurs when an attacker captures a valid signature and tries to use it again. You must track used challenges and reject any attempt to reuse them:

// Use Redis or similar cache with TTL
export async function preventReplay(
  address: string,
  nonce: string
): Promise<void> {
  const key = `used_challenge:${address}:${nonce}`;

  // We check if this challenge has already been used
  const exists = await redis.exists(key);
  if (exists) {
    throw new Error('Challenge already used');
  }

  // We mark this challenge as used for the next hour
  await redis.setex(key, 3600, '1');
}

This function should be called during signature verification, before issuing an authentication token. Even if an attacker somehow captures a valid signature, they will only be able to use it once.

Implementing Rate Limiting

Without rate limiting, an attacker can spam your challenge endpoint, potentially causing denial of service or generating challenges to use in phishing attacks. Here is how to implement rate limiting:

// Rate limit challenge requests
export async function rateLimitChallenge(
  address: string
): Promise<void> {
  const key = `challenge_rate:${address}`;

  // We increment the counter for this address
  const count = await redis.incr(key);

  // On first request, we set a one-minute expiration
  if (count === 1) {
    await redis.expire(key, 60);
  }

  // If the user has exceeded the limit, we reject the request
  if (count > 5) {
    throw new Error('Too many challenge requests');
  }
}

This rate limiter allows five challenge requests per minute per address. Adjust these numbers based on your application's needs, but be conservative to prevent abuse.

Tracking Authentication Attempts

Every authentication attempt, whether successful or failed, should be logged for security monitoring. This helps you detect and respond to attacks in real-time:

// Log authentication attempts
export async function logAuthAttempt(
  address: string,
  success: boolean,
  req: NextRequest
): Promise<void> {
  // We extract the IP address from the request headers
  const ip = req.headers.get('x-forwarded-for') ||
             req.headers.get('x-real-ip') ||
             'unknown';

  const userAgent = req.headers.get('user-agent') || 'unknown';

  // We store the authentication attempt in our database
  await db.authLog.create({
    data: {
      address,
      success,
      ipAddress: ip,
      userAgent,
      timestamp: new Date()
    }
  });

  // We check for suspicious patterns that might indicate an attack
  await detectAnomalies(address, ip);
}

This logging function captures enough information to reconstruct what happened during an authentication attempt without storing sensitive data like signatures or private keys.

Securing Your JWT Tokens

The JSON Web Tokens you issue after successful authentication must be created and validated securely. Here is how to implement secure token handling:

// Use secure JWT practices
export function createSecureToken(userId: string): string {
  return jwt.sign(
    {
      userId,
      type: 'auth',
      iat: Math.floor(Date.now() / 1000)
    },
    process.env.JWT_SECRET!,
    {
      expiresIn: '7d',
      algorithm: 'HS256',
      issuer: 'app.example.com',
      audience: 'app.example.com'
    }
  );
}

// Verify token middleware
export async function verifyToken(
  token: string
): Promise<TokenPayload> {
  try {
    const payload = jwt.verify(
      token,
      process.env.JWT_SECRET!,
      {
        algorithms: ['HS256'],
        issuer: 'app.example.com',
        audience: 'app.example.com'
      }
    );

    return payload as TokenPayload;
  } catch (error) {
    throw new Error('Invalid token');
  }
}

Always specify the algorithm explicitly when creating and verifying tokens. This prevents algorithm confusion attacks where an attacker tries to use a different algorithm than intended.


Deploying to Production

When you are ready to deploy your Bitcoin authentication system to production, there are several critical configuration and monitoring steps you must complete.

Configuring Environment Variables

Your production environment needs specific configuration variables to operate securely. Here is what you need:

# Wallet integration credentials
NEXT_PUBLIC_HANDCASH_APP_ID=your_app_id
HANDCASH_APP_SECRET=your_app_secret

# JWT configuration
JWT_SECRET=your-256-bit-secret-key
JWT_EXPIRY=7d

# Database and cache
REDIS_URL=redis://localhost:6379
DATABASE_URL=postgresql://user:pass@host:5432/db

# Rate limiting configuration
RATE_LIMIT_CHALLENGES=5
RATE_LIMIT_WINDOW=60

# Security settings
ALLOWED_ORIGINS=https://app.example.com,https://www.example.com

The JWT secret must be a cryptographically random string of at least 256 bits. Never use a weak or predictable secret, as this would allow attackers to forge authentication tokens.

Setting Up Monitoring and Alerts

You need to monitor your authentication system for anomalous behavior that might indicate an attack. Here is a monitoring class that tracks authentication events:

// lib/monitoring/auth-monitor.ts
export class AuthMonitor {
  async trackAuthEvent(event: AuthEvent): Promise<void> {
    // We log every authentication event to our monitoring service
    await this.logToService(event);

    // We check if this event looks suspicious
    if (await this.isAnomalous(event)) {
      await this.triggerAlert(event);
    }
  }

  private async isAnomalous(event: AuthEvent): Promise<boolean> {
    // We count recent failures from the same IP address
    const recentFailures = await this.getRecentFailures(event.ip);
    if (recentFailures > 10) return true;

    // We check if the user is authenticating from an unusual location
    if (await this.isUnusualLocation(event.address, event.ip)) {
      return true;
    }

    return false;
  }

  private async triggerAlert(event: AuthEvent): Promise<void> {
    // We send an alert to our incident response system
    await fetch('https://alerts.example.com/webhook', {
      method: 'POST',
      body: JSON.stringify({
        type: 'auth_anomaly',
        event,
        timestamp: new Date().toISOString()
      })
    });
  }
}

This monitoring system sends alerts when it detects patterns that might indicate an attack, such as many failed attempts from the same IP address or authentication attempts from unusual geographic locations.

Writing Tests for Your Authentication System

Before deploying to production, you must have comprehensive tests that verify your authentication system works correctly and securely. Here are essential test cases:

// __tests__/auth/bitcoin-auth.test.ts
import { generateChallenge, verifySignature } from '@/lib/auth/bitcoin';

describe('Bitcoin Authentication', () => {
  it('generates valid challenge', async () => {
    const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa';
    const challenge = await generateChallenge(address);

    // We verify that a challenge was created
    expect(challenge).toBeTruthy();

    // We parse and verify the challenge structure
    const parsed = JSON.parse(challenge);
    expect(parsed.address).toBe(address);
    expect(parsed.nonce).toHaveLength(32);
    expect(parsed.timestamp).toBeGreaterThan(Date.now() - 1000);
  });

  it('verifies valid signature', async () => {
    const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa';
    const message = 'Test message';
    const signature = 'IFqUo3h...Rz5qk=';

    const isValid = await verifySignature(address, message, signature);
    expect(isValid).toBe(true);
  });

  it('rejects invalid signature', async () => {
    const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa';
    const message = 'Test message';
    const signature = 'InvalidSignature';

    const isValid = await verifySignature(address, message, signature);
    expect(isValid).toBe(false);
  });

  it('prevents replay attacks', async () => {
    const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa';
    const challenge = await generateChallenge(address);
    const signature = await signMessage(challenge);

    // The first verification attempt should succeed
    const firstAttempt = await verifySignature(address, challenge, signature);
    expect(firstAttempt).toBe(true);

    // The second attempt with the same signature should fail
    await expect(
      verifySignature(address, challenge, signature)
    ).rejects.toThrow('Challenge already used');
  });
});

These tests verify that your authentication system generates valid challenges, correctly verifies signatures, rejects invalid signatures, and prevents replay attacks. Run these tests before every deployment to ensure your system remains secure.


Avoiding Common Pitfalls

Even experienced developers make mistakes when implementing Bitcoin authentication. Here are the most common pitfalls and how to avoid them.

Pitfall One: Not Handling Wallet Connection Failures

Users might not have a Bitcoin wallet installed, or they might deny your connection request. You must handle these cases gracefully:

// This is the wrong way - it does not handle errors
async function connect() {
  const wallet = await handcash.connect();
  return wallet;
}

// This is the correct way - it provides helpful error messages
async function connect() {
  try {
    const wallet = await handcash.connect();
    return wallet;
  } catch (error) {
    if (error.code === 'USER_DENIED') {
      throw new Error('Please approve the connection request in your wallet');
    }
    if (error.code === 'WALLET_NOT_FOUND') {
      throw new Error('Please install a Bitcoin wallet to continue');
    }
    throw error;
  }
}

The second version provides clear, actionable error messages that help users understand what went wrong and how to fix it.

Pitfall Two: Not Validating Challenge Expiration

You must always verify that a challenge has not expired before accepting the signature. Failing to do this allows attackers to use old challenges:

// You must always validate the timestamp
const challenge = JSON.parse(message);
if (Date.now() > challenge.expiresAt) {
  throw new Error('Challenge expired');
}

This check should happen in your signature verification function, after verifying that the challenge exists but before verifying the signature itself.

Pitfall Three: Storing Private Keys

This should go without saying, but you must never ask for, store, or transmit private keys. Your application should only ever work with Bitcoin addresses and signatures:

// Never do this - private keys should never leave the user's wallet
const privateKey = request.body.privateKey;

// Only work with addresses and signatures
const address = request.body.address;
const signature = request.body.signature;

If you find yourself working with private keys anywhere in your authentication code, you have made a serious security mistake and must redesign your implementation.

Pitfall Four: Not Rate Limiting

Without rate limits, attackers can spam your endpoints and potentially cause service degradation or generate challenges for phishing attacks. Always implement rate limiting:

// Always rate limit before generating challenges
await rateLimitChallenge(address);
const challenge = await generateChallenge(address);

The rate limiting should be strict enough to prevent abuse but generous enough to not frustrate legitimate users who might need to retry authentication.


Advanced Topics and Next Steps

Once you have a working Bitcoin authentication system, there are several advanced topics you might want to explore.

Hybrid Authentication Systems

Many applications need to support multiple authentication methods. You can design a hybrid system that supports Bitcoin authentication alongside traditional methods:

// lib/auth/hybrid-auth.ts
export async function authenticateUser(
  method: 'bitcoin' | 'oauth' | 'email',
  credentials: any
): Promise<AuthResult> {
  switch (method) {
    case 'bitcoin':
      return authenticateWithBitcoin(credentials);
    case 'oauth':
      return authenticateWithOAuth(credentials);
    case 'email':
      return authenticateWithEmail(credentials);
  }
}

This approach allows users to choose their preferred authentication method while you maintain a single user database and session management system.

Advanced Features to Consider

Once your basic system is working, you might want to implement advanced features. Session management with refresh tokens allows users to stay logged in across multiple devices. Signature delegation enables applications to sign messages on behalf of users for specific purposes. Key rotation support allows users to change their Bitcoin address while maintaining their account. Social recovery mechanisms using multi-signature schemes can help users recover access if they lose their wallet.


Resources and Further Reading

If you are building Bitcoin authentication for Express.js applications, the BSV Blockchain team provides official middleware that implements all the patterns described in this guide. You can install it with:

npm install @bsv-blockchain/auth-express-middleware

Here is how to use it in an Express application:

import express from 'express';
import { authMiddleware } from '@bsv-blockchain/auth-express-middleware';

const app = express();

app.use(authMiddleware({
  challengeExpiry: 300,  // Challenges expire after five minutes
  sessionExpiry: 604800  // Sessions last for seven days
}));

app.post('/api/login', (req, res) => {
  // The middleware handles all authentication logic
  res.json({ user: req.user });
});

This middleware follows BSV best practices and is maintained by the BSV Blockchain team, making it the recommended approach for Express.js backends.

Other Useful Libraries

The bsv library provides low-level Bitcoin functionality including signature verification. The HandCash Connect SDK handles HandCash wallet integration. The bitcoinfiles library enables Bitcoin-based file operations. The bsv-message library provides message signing and verification utilities.

Standards and Specifications

The BRC-77 standard defines the modern Bitcoin authentication protocol. BIP-137 specifies the original Bitcoin message signing format. The ECDSA algorithm provides the underlying cryptographic security.

Testing Tools

Bitcoin testnet faucets provide free test coins for development. The HandCash sandbox environment allows testing HandCash integration without using real money. Running a local Bitcoin node gives you a private blockchain for testing.


Getting Help

If you need assistance integrating Bitcoin authentication into your application, the b0ase.com team specializes in Web3 authentication systems. You can book a technical consultation through our contact page, browse our library of Web3 components, or reach out directly with questions via email at hello@b0ase.com or on Telegram at https://t.me/b0ase_com.


This guide is maintained by the b0ase.com development team and was last updated on January 15, 2026.

More ArticlesGet in Touch

Company

  • About
  • Services
  • Portfolio
  • Contact

Resources

  • Components
  • Brand Kit
  • Skills
  • Tools

Build

  • Start a Project
  • Join a Team
  • Launch a Token
  • Automation

Legal

  • Privacy Policy
  • Terms of Service

© 2026 b0ase. All rights reserved.