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.
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:
Request a Challenge
Sign the Challenge
Verify and Authenticate
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 FalseSecurity: 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
- ADR-036: Arbitrary Message Signing — The Cosmos standard we implement
- Keplr API Documentation — Complete API reference for Keplr integration
- cosmpy Library — Python toolkit for Cosmos cryptography
- Get ASI Wallet — Download the ASI Alliance wallet extension
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.