Developer Guide

How to Add ASI Alliance Wallet Login to Your App

A complete guide to integrating Keplr and ASI Alliance wallet authentication. No email. No password. Just cryptographic proof of wallet ownership.

January 2026•10 min read

Web3 users expect to authenticate with their wallets. If you're building in the ASI Alliance ecosystem, you'll want to support Keplr and ASI Wallet login. This guide shows you how.

We'll implement the challenge-sign-verify pattern: your backend issues a unique challenge, the user signs it with their wallet's private key, and your backend verifies the signature to prove wallet ownership. No passwords transmitted. No credentials stored.

Why Wallet Authentication?

For Web3 applications, wallet authentication provides advantages over traditional email/password systems:

No Password Database

Users prove identity cryptographically. You never store or transmit passwords.

Unified Identity

One wallet address across every app. Users don't need to remember another username.

Built-in Key Management

Keplr and ASI Wallet handle key storage securely. You don't need to implement it.

Architecture Overview

The authentication flow has three steps:

1

Request a Challenge

Your frontend sends the user's wallet address to your backend. The backend generates a unique, time-limited challenge message (a nonce) and returns it.
2

Sign the Challenge

The user's wallet (Keplr or ASI Wallet) prompts them to sign the challenge message. The wallet returns the signature and public key.
3

Verify and Authenticate

Your frontend sends the signature and public key to your backend. The backend verifies the signature matches the wallet address and issues a session token (JWT).

The Flow Diagram:

User        Frontend        Backend         Wallet
  |             |               |               |
  |--Connect--->|               |               |
  |             |--Challenge--->|               |
  |             |<--Nonce-------|               |
  |             |--Sign Nonce----------------->|
  |             |<--Signature + PubKey---------|
  |             |--Verify------>|               |
  |             |<--JWT---------|               |
  |<--Session---|               |               |

Prerequisites

  • Keplr or ASI Wallet extension — Users need one of these installed in their browser
  • Python backend (or any language) — We'll show Python examples, but the concepts apply anywhere
  • cosmpy library — For Cosmos cryptography: pip install cosmpy
  • PyJWT library — For JWT tokens: pip install PyJWT[crypto]

Frontend: Connecting and Signing

1. Detect Wallet Extensions

Check if Keplr or ASI Wallet is installed. Both inject a keplr object into the window (ASI Wallet is a Keplr fork):

function detectWallet(): boolean {
  return typeof window !== 'undefined' && !!window.keplr;
}

async function connectWallet(chainId: string): Promise<string> {
  if (!window.keplr) {
    throw new Error('Please install Keplr or ASI Wallet');
  }
  
  // Request access to the chain
  await window.keplr.enable(chainId);
  
  // Get the user's address
  const offlineSigner = window.keplr.getOfflineSigner(chainId);
  const accounts = await offlineSigner.getAccounts();
  
  return accounts[0].address; // e.g., "fetch1abc123..."
}

2. Request a Challenge from Your Backend

Once connected, ask your backend for a challenge to sign:

interface ChallengeResponse {
  message: string;    // The challenge to sign
  expires_at: string; // When the challenge expires
}

async function requestChallenge(address: string): Promise<ChallengeResponse> {
  const response = await fetch('/api/auth/wallet/challenge', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ address }),
  });
  
  if (!response.ok) throw new Error('Failed to get challenge');
  return response.json();
}

3. Sign the Challenge with ADR-036

Use Keplr's signArbitrary method. This follows the Cosmos ADR-036 standard for signing arbitrary data:

interface SignatureResult {
  signature: string;  // Base64-encoded signature
  pub_key: {
    type: string;     // Key type (usually secp256k1)
    value: string;    // Base64-encoded public key
  };
}

async function signChallenge(
  chainId: string,
  signer: string,
  message: string
): Promise<SignatureResult> {
  if (!window.keplr) {
    throw new Error('Wallet not available');
  }
  
  // Keplr will prompt the user to approve the signature
  const result = await window.keplr.signArbitrary(
    chainId,
    signer,   // The wallet address
    message   // The challenge message from your backend
  );
  
  return result;
}

What the User Sees

When signArbitrary is called, the wallet extension opens a popup showing the message to be signed. The user must click "Approve" to continue. If they reject, your code receives an error.

4. Send Signature to Backend

Send the signature and public key to your verification endpoint:

interface AuthResponse {
  access_token: string;
  refresh_token: string;
  expires_at: string;
  user_id: string;
}

async function verifySignature(
  address: string,
  signature: string,
  pubKey: { type: string; value: string },
  message: string
): Promise<AuthResponse> {
  const response = await fetch('/api/auth/wallet/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      address,
      signature,
      pub_key: pubKey,
      signed_message: message,
    }),
  });
  
  if (!response.ok) throw new Error('Verification failed');
  return response.json();
}

Backend: Verification and JWT Issuance

1. Generate Secure Challenges

Create unique, time-limited challenges using a secure random generator:

import secrets
import hashlib
from datetime import datetime, timedelta
from django.core.cache import cache  # Or Redis, etc.

CHALLENGE_EXPIRY_SECONDS = 300  # 5 minutes

def generate_challenge(address: str) -> dict:
    """Generate a unique challenge for wallet authentication."""
    # Create a random nonce
    nonce = secrets.token_hex(32)
    timestamp = datetime.utcnow().isoformat()
    
    # Build a human-readable challenge message
    message = f"Sign this message to authenticate with ASI:One.\n\n" \
              f"Wallet: {address}\n" \
              f"Nonce: {nonce}\n" \
              f"Timestamp: {timestamp}"
    
    # Store the challenge temporarily (prevents replay attacks)
    cache_key = f"wallet_challenge:{address}"
    expires_at = datetime.utcnow() + timedelta(seconds=CHALLENGE_EXPIRY_SECONDS)
    
    cache.set(cache_key, {
        "message": message,
        "nonce": nonce,
        "expires_at": expires_at.isoformat(),
    }, timeout=CHALLENGE_EXPIRY_SECONDS)
    
    return {
        "message": message,
        "expires_at": expires_at.isoformat(),
    }

2. Verify Cosmos Signatures

Use cosmpy to verify that the signature was created by the wallet's private key:

import base64
import hashlib
from cosmpy.crypto.keypairs import PublicKey
from cosmpy.aerial.wallet import LocalWallet

def verify_cosmos_signature(
    address: str,
    signature_b64: str,
    public_key_b64: str,
    message: str
) -> bool:
    """
    Verify an ADR-036 signature from Keplr/ASI Wallet.
    
    Returns True if the signature is valid and matches the address.
    """
    try:
        # Decode the signature and public key from base64
        signature_bytes = base64.b64decode(signature_b64)
        public_key_bytes = base64.b64decode(public_key_b64)
        
        # Create a PublicKey object
        public_key = PublicKey(public_key_bytes)
        
        # Derive the address from the public key
        derived_address = str(public_key.address("fetch"))
        
        # Verify the address matches
        if derived_address.lower() != address.lower():
            return False
        
        # ADR-036: Hash the message with SHA256
        message_hash = hashlib.sha256(message.encode()).digest()
        
        # Verify the signature
        return public_key.verify(message_hash, signature_bytes)
        
    except Exception as e:
        # Log the error for debugging, but don't expose details
        print(f"Signature verification failed: {e}")
        return False

Security: Address Derivation

Always derive the address from the public key—never trust the address sent by the client. This prevents attackers from claiming ownership of addresses they don't control.

3. Issue JWT Tokens

After verification, create a JWT for the user session. Use RS256 (asymmetric) for production:

import jwt
import uuid
from datetime import datetime, timedelta

# Load your RSA private key (from environment variable in production)
PRIVATE_KEY = open("private_key.pem").read()
PUBLIC_KEY = open("public_key.pem").read()
TOKEN_LIFETIME = timedelta(hours=1)
REFRESH_TOKEN_LIFETIME = timedelta(days=30)

def create_token_pair(user_id: str, wallet_address: str) -> dict:
    """Create access and refresh tokens for an authenticated user."""
    now = datetime.utcnow()
    token_id = str(uuid.uuid4())
    
    # Access token - short-lived
    access_claims = {
        "sub": user_id,
        "wallet": wallet_address,
        "iat": now,
        "exp": now + TOKEN_LIFETIME,
        "jti": token_id,
        "type": "access",
    }
    access_token = jwt.encode(access_claims, PRIVATE_KEY, algorithm="RS256")
    
    # Refresh token - long-lived
    refresh_claims = {
        "sub": user_id,
        "iat": now,
        "exp": now + REFRESH_TOKEN_LIFETIME,
        "jti": str(uuid.uuid4()),
        "type": "refresh",
        "access_jti": token_id,  # Links to the access token
    }
    refresh_token = jwt.encode(refresh_claims, PRIVATE_KEY, algorithm="RS256")
    
    return {
        "access_token": access_token,
        "refresh_token": refresh_token,
        "expires_at": (now + TOKEN_LIFETIME).isoformat(),
        "user_id": user_id,
    }

def verify_token(token: str, token_type: str = "access") -> dict:
    """Verify and decode a JWT token."""
    try:
        claims = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
        if claims.get("type") != token_type:
            raise jwt.InvalidTokenError("Wrong token type")
        return claims
    except jwt.ExpiredSignatureError:
        raise ValueError("Token has expired")
    except jwt.InvalidTokenError as e:
        raise ValueError(f"Invalid token: {e}")

4. Complete Verification Endpoint

Here's the full verification endpoint bringing it all together:

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from django.core.cache import cache

router = APIRouter()

class VerifyRequest(BaseModel):
    address: str
    signature: str
    pub_key: dict  # { "type": "...", "value": "..." }
    signed_message: str

@router.post("/auth/wallet/verify")
def verify_wallet(payload: VerifyRequest):
    # 1. Check the challenge exists and hasn't expired
    cache_key = f"wallet_challenge:{payload.address}"
    stored_challenge = cache.get(cache_key)
    
    if not stored_challenge:
        raise HTTPException(400, "Challenge not found or expired")
    
    if stored_challenge["message"] != payload.signed_message:
        raise HTTPException(400, "Message does not match challenge")
    
    # 2. Delete the challenge (one-time use)
    cache.delete(cache_key)
    
    # 3. Verify the signature
    is_valid = verify_cosmos_signature(
        address=payload.address,
        signature_b64=payload.signature,
        public_key_b64=payload.pub_key["value"],
        message=payload.signed_message,
    )
    
    if not is_valid:
        raise HTTPException(401, "Invalid signature")
    
    # 4. Get or create the user
    user, created = User.objects.get_or_create(
        primary_wallet_address=payload.address,
        defaults={"username": payload.address[:20]},
    )
    
    # 5. Issue tokens
    return create_token_pair(
        user_id=str(user.id),
        wallet_address=payload.address,
    )

Security Best Practices

Expire Challenges Quickly

Set a 5-minute or shorter expiry on challenges. Longer windows increase replay attack risk.

One-Time Use Nonces

Delete challenges immediately after use. Never allow a signature to be reused.

Implement Token Refresh

Use short-lived access tokens (1 hour) with long-lived refresh tokens (30 days). Refresh tokens should be stored securely.

Derive Address from Public Key

Never trust the address from the request body. Always derive it from the public key to verify ownership.

Use RS256 in Production

Asymmetric keys let you verify tokens without exposing the signing key. Keep your private key secure.

Generate RSA Keys for Production

Generate a key pair using OpenSSL. Store the private key securely (environment variable, secrets manager):

# Generate private key
openssl genrsa -out private_key.pem 2048

# Extract public key
openssl rsa -in private_key.pem -pubout -out public_key.pem

Full Frontend Flow Example

Here's a complete React hook that handles the entire wallet authentication flow:

import { useState, useCallback } from 'react';

const CHAIN_ID = 'fetchhub-4';  // Or your chain

export function useWalletAuth() {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  
  const authenticate = useCallback(async () => {
    setIsLoading(true);
    setError(null);
    
    try {
      // 1. Connect wallet
      if (!window.keplr) {
        throw new Error('Please install Keplr or ASI Wallet');
      }
      await window.keplr.enable(CHAIN_ID);
      const signer = window.keplr.getOfflineSigner(CHAIN_ID);
      const [account] = await signer.getAccounts();
      const address = account.address;
      
      // 2. Get challenge
      const challengeRes = await fetch('/api/auth/wallet/challenge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ address }),
      });
      const { message } = await challengeRes.json();
      
      // 3. Sign challenge
      const signResult = await window.keplr.signArbitrary(
        CHAIN_ID,
        address,
        message
      );
      
      // 4. Verify and get tokens
      const verifyRes = await fetch('/api/auth/wallet/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          address,
          signature: signResult.signature,
          pub_key: signResult.pub_key,
          signed_message: message,
        }),
      });
      
      if (!verifyRes.ok) {
        throw new Error('Authentication failed');
      }
      
      const tokens = await verifyRes.json();
      
      // 5. Store tokens (use httpOnly cookies in production)
      localStorage.setItem('access_token', tokens.access_token);
      localStorage.setItem('refresh_token', tokens.refresh_token);
      
      return tokens;
    } catch (err) {
      setError(err.message);
      throw err;
    } finally {
      setIsLoading(false);
    }
  }, []);
  
  return { authenticate, isLoading, error };
}

Testing Your Implementation

Test Checklist:

  • Happy path: Connect → Sign → Verify → Get tokens
  • Expired challenge: Wait past expiry, verify rejection
  • Replay attack: Reuse a signature, verify rejection
  • User rejection: Decline signature prompt, handle error
  • Token refresh: Access token expires, refresh works
  • Wrong address: Tamper with address, verify rejection

Resources

Start Building

You now have everything you need to add wallet authentication to your ASI Alliance application. The challenge-sign-verify pattern is battle-tested and works with any Cosmos-compatible wallet.