Web3 Authentication

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

  1. Get Address: Retrieve the user's stake address from the connected wallet.

  2. Create Payload: Construct a unique message including the stake address to prevent replay attacks (e.g., "Auth for MyDApp: stake1u...").

  3. Sign Payload: Use the wallet's signData function to sign the payload. The wallet returns a COSE_Sign1 structure (the proof).

  4. Send Proof: Send the COSE_Sign1 proof 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 address in 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 signature against the protected header and payload.

    • This confirms the proof was signed by the user's private key and hasn't been tampered with.

  • 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 uri of the request origin and a specific action description. 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

  1. Render Authenticated UI State: Once the server has verified the signature, it can grant access to the user's authenticated UI state.


Full Examples

https://github.com/Cardano-Forge/anvil-api/blob/main/docs/developer-tools/guides/transaction/README.md

Last updated

Was this helpful?