Merchant Authentication
GuideHow 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:
- Signed Intents (Recommended) - Your backend signs the transaction data with an Ed25519 key
- 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
| Feature | Description |
|---|---|
| Ed25519 Signature | Cryptographically proves the request came from your backend |
| Nonce | Each intent has a unique ID, preventing replay attacks |
| Expiry | Intents expire after a set time (default: 5 minutes) |
| Canonical Format | Deterministic 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
- Register your merchant account and get an API key
- Register your allowed domains
- 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.b64Store 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:
- Parse request - Extract signature, nonce, expiry, and calls
- Check expiry - Reject if
Date.now() > expiresAt - Check nonce - Reject if nonce was already used (replay protection)
- Look up merchant - Find by
merchantId, verify status is ACTIVE - Recreate canonical message - Build the same JSON that was signed
- Verify signature - Use merchant's public key to verify Ed25519 signature
- Store nonce - Mark as used to prevent replays
- Continue - Show transaction to user for passkey signing
If any step fails, the request is rejected with an appropriate error.