Developer Tools
CIP-8: "Sign-in with Cardano" Guide#
To "Sign in with Cardano," users cryptographically prove control of their stake address by signing a unique challenge with their wallet’s private key via CIP-8. This guide shows how to integrate Weld for wallet interactions, craft and sign challenges, verify signatures on your server, and extend authentication with on-chain business logic. (i.e. NFT gating, checking delegation status, wallet whitelisting, etc...)
Core Components#
- FrontEnd UI (Browser): Initiates the request. We recommend a library like Weld to handle different wallet APIs.
- Wallet UI (e.g., Lace, Vespr, Eternl): Holds the user's private key and creates the signature.
- Backend Server: Verifies the signature to authenticate the user.
Authentication Flow#
1. FrontEnd UI: Create and Sign Payload#
- Get Address: Retrieve the user's stake address from the connected wallet.
- Create Payload: Construct a unique message including the stake address to prevent replay attacks (e.g.,
"Auth for MyDApp: stake1u..."). - Sign Payload: Use the wallet's
signDatafunction to sign the payload. The wallet returns aCOSE_Sign1structure (the proof). - Send Proof: Send the
COSE_Sign1proof and the user's public key to the backend server.
2. Backend Server: Verify Proof & Apply Business Logic#
The server receives the COSE_Sign1 proof, which contains a protected header (with the user's address), the original payload, and the signature. It must perform these checks:
Check 1: Key Matches Address
- Hash the public key received from the client.
- Extract the public key hash from the
addressin the proof's protected header. - Verify they match. This confirms the public key belongs to the address in the proof.
Check 2: Signature is Valid
- Using a crypto library, verify the
signatureagainst theprotected headerandpayload. - This confirms the proof was signed by the user's private key and hasn't been tampered with.
- Using a crypto library, verify the
Check 3: Business Logic Hooks (Optional) With the address cryptographically verified, you can now implement custom business logic. Examples include:
- Token/NFT Gating: Query a Cardano API (e.g., Blockfrost, Koios) to confirm the wallet's UTXOs contain a required asset policy ID or fingerprint.
- Stake & Delegation Checks: Use the stake address to check delegation status, stake pool, or total ADA balance against your requirements.
- Wallet Whitelists/Blacklists: For higher security applications, you might maintain a list of approved stake addresses.
- Session Management: If you're using a session-based authentication system, set a secure cookie on the client to grant access.
- Replay Protection: The payload should include a
timestamp. On the server, enforce a time-to-live (TTL) to reject signatures that are too old. - Domain Separation: The payload should include the
uriof the request origin and a specificactiondescription. The server must validate these fields to prevent a signature from one domain being reused on another (phishing) and to ensure the user approved the specific action intended.
Implementation Details: Full Backend Verification
While working directly with Cardano's serialization libraries (CSL) can have a steep learning curve, you don't need to be an expert to implement secure authentication. The function below encapsulates the entire CIP-8 verification process. We'll use this similar functions in the full examples provided later, giving you a practical, production-ready tool for validating signature packages.
import {
COSESign1,
COSEKey,
Label,
Int,
BigNum
} from '@emurgo/cardano-message-signing-asmjs';
import {
Address,
Ed25519Signature,
PublicKey,
RewardAddress,
} from '@emurgo/cardano-serialization-lib-asmjs';
export async function verifyCIP8Signature(
sigData: { signature: string; key?: string },
options: { expectedDomain: string; expectedAction: string }
): Promise<{ isValid: boolean; stakeAddress: string | null }> {
try {
if (typeof sigData !== 'object' || !sigData.signature || !sigData.key) {
console.error('Invalid signature data format');
return { isValid: false, stakeAddress: null };
}
// 1. Decode the signature and key
const decoded = COSESign1.from_bytes(hexToBytes(sigData.signature));
const key = COSEKey.from_bytes(hexToBytes(sigData.key));
const headermap = decoded.headers().protected().deserialized_headers();
const address = Address.from_bytes(headermap.header(Label.new_text('address')).to_bytes());
const pubKeyBytes = key.header(Label.new_int(Int.new_negative(BigNum.from_str('2')))).as_bytes();
const publicKey = PublicKey.from_bytes(pubKeyBytes);
// 2. Verify the cryptographic signature
const signatureBytes = Ed25519Signature.from_bytes(decoded.signature());
const signedData = decoded.signed_data().to_bytes();
const isSignatureValid = publicKey.verify(signedData, signatureBytes);
if (!isSignatureValid) throw new Error('Cryptographic signature is invalid.');
// 3. Parse and validate the payload for security
const payloadBytes = decoded.payload();
if (!payloadBytes) throw new Error('No payload in signature');
const payload = JSON.parse(new TextDecoder().decode(payloadBytes));
// 4. Replay Protection: Check if the timestamp is recent
const TTL = 5 * 60 * 1000; // 5 minutes
const isTimestampValid = Date.now() - payload.timestamp < TTL;
if (!isTimestampValid) throw new Error('Timestamp is too old, possible replay attack.');
// 5. Domain Separation: Check URI and action to prevent phishing
const isDomainValid = payload.uri === options.expectedDomain;
const isActionValid = payload.action === options.expectedAction;
if (!isDomainValid || !isActionValid) throw new Error('Domain or action mismatch.');
// 6. Final verification: Extract stake address
const signerStakeAddrBech32 = RewardAddress.from_address(address)?.to_address()?.to_bech32();
if (!signerStakeAddrBech32) throw new Error('Could not extract stake address.');
console.log('CIP-8 verification successful for:', signerStakeAddrBech32);
return { isValid: true, stakeAddress: signerStakeAddrBech32 };
} catch (error) {
console.error('CIP-8 verification error:', error);
return { isValid: false, stakeAddress: null };
}
}
export function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(Math.floor(hex.length / 2));
for (let i = 0; i < bytes.length; i++) {
const hexByte = hex.substring(i * 2, i * 2 + 2);
bytes[i] = parseInt(hexByte, 16);
}
return bytes;
}3. FrontEnd UI: Render Authenticated UI State#
- Render Authenticated UI State: Once the server has verified the signature, it can grant access to the user's authenticated UI state.