šŸ”1auth SDK Docs
Back to documentation

Merchant Authentication

Guide

How to authenticate your app to send intents and transactions.

Overview

When your app wants to send transactions (intents) through the passkey signer, it must authenticate to prove the request is legitimate. This prevents malicious websites from submitting transactions on your users' behalf.

The system supports two authentication methods:

  1. Signed Intents (Recommended) - Your backend signs the transaction data with an Ed25519 key
  2. Origin Verification - Simpler setup for first-party apps with registered origins

Quick Start

The fastest way to add merchant signing is with createSignIntentHandler:

1. Create API Route

// app/api/sign-intent/route.ts
import { createSignIntentHandler } from "@1auth/sdk/server";

export const POST = createSignIntentHandler({
  merchantId: process.env.MERCHANT_ID!,
  privateKey: process.env.MERCHANT_PRIVATE_KEY!,
});

2. Use with PayButton

import { PayButton } from "@1auth/sdk/react";
import { PasskeyProviderClient } from "@1auth/sdk";

const client = new PasskeyProviderClient({
  providerUrl: process.env.NEXT_PUBLIC_AUTH_URL!,
  clientId: "my-app",
});

function CheckoutButton({ intent }) {
  const getSignedIntent = async ({ username, targetChain, calls, tokenRequests }) => {
    const res = await fetch("/api/sign-intent", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ username, targetChain, calls, tokenRequests }),
    });
    return res.json();
  };

  return (
    <PayButton
      client={client}
      intent={intent}
      getSignedIntent={getSignedIntent}
    >
      Pay with 1auth
    </PayButton>
  );
}

This provides XSS protection by ensuring transaction calls are constructed and signed on your backend.

Coming Soon: Signed intents will support sponsorship flags, allowing merchants to sponsor transaction fees for their users. This feature requires signed intents to ensure only authorized merchants can enable sponsorship.

Signed Intents (Recommended)

Signed intents provide the strongest security. Your backend signs the transaction details, ensuring they cannot be modified in transit.

How It Works

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  Your Backend                       │
│                                     │
│  1. Build transaction calls         │
│  2. Sign with Ed25519 private key   │
│  3. Send signed intent to frontend  │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
               │
               ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  Your Frontend (SDK)                │
│                                     │
│  4. Call sendIntent({ signedIntent})│
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
               │
               ā–¼
ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  Passkey Service                    │
│                                     │
│  5. Verify Ed25519 signature        │
│  6. Check nonce (replay protection) │
│  7. Show transaction to user        │
│  8. User signs with passkey         │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

Server-Side Signing

Use the SDK's server utilities to sign intents:

// Your backend (e.g., Next.js API route)
import { signIntent } from '@1auth/sdk/server';

export async function POST(request: Request) {
  const { username, productId } = await request.json();

  // Build the transaction calls
  const calls = [
    {
      to: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD38',
      data: encodeFunctionData({
        abi: shopAbi,
        functionName: 'purchase',
        args: [productId],
      }),
      value: '1000000000000000000', // 1 ETH in wei
      label: 'Purchase Item',
      sublabel: `Product #${productId}`,
    },
  ];

  // Sign the intent
  const signedIntent = signIntent(
    {
      username,
      targetChain: 8453, // Base
      calls,
    },
    {
      merchantId: process.env.MERCHANT_ID!,
      privateKey: process.env.MERCHANT_PRIVATE_KEY!, // Base64 Ed25519
    }
  );

  return Response.json({ signedIntent });
}

Frontend Usage

Pass the signed intent to the SDK:

// Your frontend
import { PasskeyProviderClient } from '@1auth/sdk';

const client = new PasskeyProviderClient({
  providerUrl: 'https://auth.example.com',
  clientId: 'my-app',
});

// Get signed intent from your backend
const { signedIntent } = await fetch('/api/create-order', {
  method: 'POST',
  body: JSON.stringify({ username: 'alice', productId: 123 }),
}).then(r => r.json());

// Send the intent
const result = await client.sendIntent({
  signedIntent,
  username: 'alice',
});

if (result.success) {
  console.log('Transaction hash:', result.transactionHash);
}

Signed Intent Structure

interface MerchantSignedIntent {
  merchantId: string;      // Your merchant ID
  targetChain: number;     // Destination chain ID
  calls: IntentCall[];     // Transaction calls (signed)
  username?: string;       // User identifier
  accountAddress?: string; // Alternative to username
  nonce: string;           // Unique ID (replay protection)
  expiresAt: number;       // Unix timestamp (ms)
  signature: string;       // Base64 Ed25519 signature
}

interface IntentCall {
  to: string;              // Target contract address
  data?: string;           // Encoded calldata (hex)
  value?: string;          // ETH value in wei
  label?: string;          // Human-readable action name
  sublabel?: string;       // Additional context
}

Canonical Message Format

The signature covers a canonical JSON message to prevent manipulation:

{
  merchantId: "your-merchant-id",
  targetChain: 8453,
  calls: [
    {
      to: "0x...",           // Lowercase
      data: "0x...",         // Or "0x" if empty
      value: "1000000000",   // Or "0" if empty
      label: "Buy NFT",
      sublabel: "Token #123"
    }
  ],
  username: "alice",         // Or null
  accountAddress: null,      // Or address if provided
  nonce: "uuid-here",
  expiresAt: 1735689600000
}

Security Features

FeatureDescription
Ed25519 SignatureCryptographically proves the request came from your backend
NonceEach intent has a unique ID, preventing replay attacks
ExpiryIntents expire after a set time (default: 5 minutes)
Canonical FormatDeterministic message format prevents signature manipulation

API Key Authentication

For simpler setups, you can use API key authentication. This requires your frontend domain to be registered with the passkey service.

Setup

  1. Register your merchant account and get an API key
  2. Register your allowed domains
  3. Include the API key in requests

Usage

const client = new PasskeyProviderClient({
  providerUrl: 'https://auth.example.com',
  clientId: 'my-app',
  apiKey: process.env.NEXT_PUBLIC_MERCHANT_API_KEY, // Optional
});

// Unsigned intent - relies on origin verification
const result = await client.sendIntent({
  username: 'alice',
  targetChain: 8453,
  calls: [
    {
      to: '0x...',
      data: '0x...',
      label: 'Transfer',
    },
  ],
});

Limitations

  • API key is exposed in frontend code
  • Origin must match registered domains exactly
  • Less secure than signed intents (calls could be modified client-side)

Merchant Registration

To use either authentication method, you need a merchant account. Register at developer.1auth.box to get your credentials.

Database Schema

model Merchant {
  id              String   @id @default(uuid())
  name            String
  apiKeyHash      String?  @unique  // SHA-256 hash
  publicKey       String?            // Ed25519 public key (Base64)
  verifiedDomains String[]           // Allowed origins
  status          MerchantStatus @default(PENDING)
  createdAt       DateTime @default(now())
}

Key Generation

Generate an Ed25519 keypair for signing:

# Generate keypair
openssl genpkey -algorithm Ed25519 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem

# Convert to Base64 DER format for the SDK
openssl pkey -in private.pem -outform DER | base64 > private.b64
openssl pkey -in public.pem -pubin -outform DER | base64 > public.b64

Store in your environment:

MERCHANT_ID="your-merchant-uuid"
MERCHANT_PRIVATE_KEY="base64-encoded-private-key"

Register the public key with the passkey service.

Complete Example

Backend API Route

// app/api/order/route.ts
import { signIntent } from '@1auth/sdk/server';
import { encodeFunctionData } from 'viem';

const SHOP_ADDRESS = '0x...';
const SHOP_ABI = [...];

export async function POST(request: Request) {
  const { username, items } = await request.json();

  // Calculate total and build calls
  const total = items.reduce((sum, item) => sum + item.price, 0n);

  const calls = [
    {
      to: SHOP_ADDRESS,
      data: encodeFunctionData({
        abi: SHOP_ABI,
        functionName: 'checkout',
        args: [items.map(i => i.id)],
      }),
      value: total.toString(),
      label: 'Checkout',
      sublabel: `${items.length} items`,
    },
  ];

  const signedIntent = signIntent(
    { username, targetChain: 8453, calls },
    {
      merchantId: process.env.MERCHANT_ID!,
      privateKey: process.env.MERCHANT_PRIVATE_KEY!,
    }
  );

  return Response.json({ signedIntent });
}

Frontend Component

'use client';

import { useState } from 'react';
import { PasskeyProviderClient } from '@1auth/sdk';

const client = new PasskeyProviderClient({
  providerUrl: process.env.NEXT_PUBLIC_AUTH_URL!,
  clientId: 'webshop',
});

export function CheckoutButton({ username, items }) {
  const [loading, setLoading] = useState(false);

  const handleCheckout = async () => {
    setLoading(true);
    try {
      // 1. Get signed intent from backend
      const { signedIntent } = await fetch('/api/order', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, items }),
      }).then(r => r.json());

      // 2. Send to passkey signer
      const result = await client.sendIntent({
        signedIntent,
        username,
      });

      if (result.success) {
        alert(`Order confirmed! TX: ${result.transactionHash}`);
      } else {
        alert(`Order failed: ${result.error?.message}`);
      }
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handleCheckout} disabled={loading}>
      {loading ? 'Processing...' : 'Checkout with Passkey'}
    </button>
  );
}

Verification Flow

When the passkey service receives a signed intent:

  1. Parse request - Extract signature, nonce, expiry, and calls
  2. Check expiry - Reject if Date.now() > expiresAt
  3. Check nonce - Reject if nonce was already used (replay protection)
  4. Look up merchant - Find by merchantId, verify status is ACTIVE
  5. Recreate canonical message - Build the same JSON that was signed
  6. Verify signature - Use merchant's public key to verify Ed25519 signature
  7. Store nonce - Mark as used to prevent replays
  8. Continue - Show transaction to user for passkey signing

If any step fails, the request is rejected with an appropriate error.