A secure, decentralized authentication system built with Coinbase Developer Platform (CDP) and Wagmi, featuring signature-based device registration and multi-device support.
This project implements a two-step authentication flow that maintains the security properties of traditional wallet-based authentication while leveraging CDP's email-based wallet creation:
- CDP Email Authentication β User verifies email and CDP creates an embedded wallet
- Signature Verification β User signs a challenge to prove wallet ownership
This approach ensures:
- β Cryptographic proof of ownership (prevents spoofing)
- β Replay attack protection (single-use nonces)
- β Multi-device support (register multiple devices per wallet)
- β No browser extensions required (embedded wallet via CDP)
- CDP Email Authentication: Passwordless authentication using email OTP
- Embedded Wallets: Non-custodial wallets created and managed by CDP
- Wagmi Integration: Full compatibility with wagmi hooks for transactions
- Signature-Based Auth: Cryptographic proof of wallet ownership
- Device Management: Register and manage multiple devices
- JWT Tokens: Secure session management with access + refresh tokens
- Multi-Chain Support: Base and Base Sepolia networks
- Type-Safe: Full TypeScript implementation
- Node.js 22+
- A CDP Portal account
- Backend API (see BACKEND_VALIDATION_GUIDE.md)
# Clone the repository
git clone <your-repo-url>
cd Client
# Install dependencies
npm install --legacy-peer-depsNote:
--legacy-peer-depsis required due to React 19 compatibility with CDP packages.
Copy the example environment file and update with your credentials:
cp .env.example .envEdit .env with your CDP credentials:
# Get these from https://portal.cdp.coinbase.com
VITE_CDP_PROJECT_ID=your-project-id-here
VITE_CDP_API_BASE_PATH=https://api.cdp.coinbase.com
# Your backend API URL
VITE_API_BASE_URL=http://localhost:3000
# Optional
VITE_APP_LOGO_URL=https://your-logo-url.com/logo.png- Sign in to CDP Portal
- Create or select a project
- Go to project settings (gear icon)
- Copy your Project ID
- Configure your allowed domain:
http://localhost:5173(for development)
npm run devThe app will be available at http://localhost:5173
npm run buildnpm run previewsrc/
βββ app/
β βββ providers/ # CDP + Wagmi provider configuration
β βββ router/ # React Router setup
β βββ store/ # Zustand state management
β
βββ pages/ # Route components
β βββ Landing/ # Authentication entry point
β βββ Dashboard/ # Main application interface
β
βββ components/ # Reusable UI components
β βββ ui/
β βββ layout/
β βββ shared/
β
βββ services/ # API and SDK integrations
β βββ api/
β βββ auth.service.ts # Authentication API calls
β
βββ hooks/ # Custom React hooks
β βββ useAuth.ts # Main authentication hook
β
βββ utils/ # Utility functions
β βββ device.ts # Device ID and info generation
β
βββ config/ # Configuration files
β βββ env.ts # Environment variables
β βββ routes.ts # Route constants
β
βββ types/ # TypeScript type definitions
βββ auth.types.ts # Authentication types
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Landing Page β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β "Get Started" Button Clicked β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CDP Email Authentication (OTP) β
β β
β 1. User enters email β
β 2. CDP sends OTP β
β 3. User enters OTP β
β 4. CDP creates embedded wallet β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Wallet Created & Available β
β β
β - Wallet address available via useAccount() β
β - isConnected = true β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Request Challenge from Backend β
β β
β GET /auth/challenge β
β Response: { challenge, nonce, expiresAt } β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Sign Challenge with Wallet β
β β
β - Use wagmi's signMessage hook β
β - User confirms signature in UI β
β - Get signature: 0x1234... β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Send Device Registration to Backend β
β β
β POST /auth/device-register β
β { β
β walletAddress: "0x742d35...", β
β signature: "0x1234...", β
β message: "Sign this message...", β
β deviceInfo: { deviceId, deviceName, userAgent } β
β } β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Backend Verifies Signature β
β β
β 1. Extract nonce from message β
β 2. Verify challenge not expired β
β 3. Recover signer address from signature β
β 4. Verify signer matches walletAddress β
β 5. Delete nonce (prevent replay) β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Register Device & Generate Tokens β
β β
β - Check if user exists (by walletAddress) β
β - Create user if new β
β - Register or update device β
β - Generate JWT access + refresh tokens β
β - Return tokens + device list β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Store Tokens & Navigate to Dashboard β
β β
β - Store tokens in localStorage β
β - Update Zustand auth state β
β - Redirect to /dashboard β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Dashboard β
β β
β - Display wallet info β
β - Show registered devices β
β - Access to authenticated features β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Configures CDP and Wagmi integration:
// src/app/providers/WagmiProvider.tsx
const cdpConfig: Config = {
projectId: env.CDP_PROJECT_ID,
ethereum: {
createOnLogin: "eoa", // Create EVM account on login
},
// ...other config
};
const connector = createCDPEmbeddedWalletConnector({
cdpConfig,
providerConfig: {
chains: [base, baseSepolia],
transports: { /* ... */ },
},
});Main authentication logic:
// src/hooks/useAuth.ts
export function useAuth() {
const { address, isConnected } = useAccount(); // Wagmi
const { signMessageAsync } = useSignMessage(); // Wagmi
const completeDeviceRegistration = async () => {
// 1. Request challenge
const challenge = await requestChallenge();
// 2. Sign challenge
const signature = await signMessageAsync({
message: challenge.challenge,
});
// 3. Register device
const response = await registerDevice({
walletAddress: address,
signature,
message: challenge.challenge,
deviceInfo: getDeviceInfo(),
});
// 4. Store tokens
storeTokens(response.tokens);
setAuth(response);
};
return { completeDeviceRegistration, /* ... */ };
}Handles the authentication flow:
// src/pages/Landing/index.tsx
export default function Landing() {
const { isConnected, completeDeviceRegistration } = useAuth();
useEffect(() => {
if (isConnected && !isAuthenticated) {
// After CDP auth, trigger device registration
completeDeviceRegistration();
}
}, [isConnected]);
return (
<div>
<AuthButton /> {/* CDP component */}
{/* Loading states, error handling */}
</div>
);
}The signature verification step ensures:
- Proof of Ownership: Only the wallet owner can produce valid signatures
- No Spoofing: Attackers cannot register devices for other wallets
- Cryptographic Security: Uses EIP-191 standard message signing
- Single-use nonces: Each challenge can only be used once
- Time-based expiration: Challenges expire after 5 minutes
- Server-side validation: Backend deletes nonce after use
- Unique device IDs: Each device has a persistent identifier
- Device tracking: Track registration and last usage times
- Multi-device support: Users can access from multiple devices
Your backend must implement these endpoints:
Generates a challenge for the user to sign.
Response:
{
"challenge": "Sign this message to authenticate: 1733356800-abc123",
"nonce": "abc123",
"expiresAt": "2025-12-04T12:00:00Z"
}Registers a device after verifying signature.
Request Body:
{
"walletAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
"signature": "0x1234...",
"message": "Sign this message to authenticate: 1733356800-abc123",
"deviceInfo": {
"deviceId": "uuid-here",
"deviceName": "Chrome on MacOS",
"userAgent": "Mozilla/5.0..."
}
}Response:
{
"devices": [...],
"limited": false,
"tokens": {
"access_token": "eyJhbGc...",
"refresh_token": "eyJhbGc..."
}
}See BACKEND_VALIDATION_GUIDE.md for complete implementation details.
Located at src/services/api/auth.service.ts
async function requestChallenge(): Promise<AuthChallenge>Requests a challenge from the backend for signing.
async function registerDevice(
request: DeviceRegistrationRequest
): Promise<DeviceRegistrationResponse>Registers a device with signature verification.
Token management utilities for localStorage.
Edit src/app/providers/WagmiProvider.tsx:
const themeOverrides = {
"colors-bg-default": "#ffffff",
"colors-bg-primary": "#0052ff",
"colors-fg-default": "#000000",
// ... more theme variables
};
<CDPReactProvider config={cdpConfig} theme={themeOverrides}>See CDP Theming Documentation for all available theme tokens.
Create hooks in src/hooks/:
// src/hooks/useWalletBalance.ts
export function useWalletBalance() {
const { address } = useAccount();
// Your custom logic
}- Start the dev server:
npm run dev - Navigate to landing page: http://localhost:5173
- Click "Get Started": Triggers CDP authentication
- Enter email: Receive OTP from CDP
- Enter OTP: Wallet is created
- Sign challenge: Approve signature request
- View dashboard: See wallet info and devices
# 1. Request challenge
curl http://localhost:3000/auth/challenge
# 2. Register device (use actual signature from frontend)
curl -X POST http://localhost:3000/auth/device-register \
-H "Content-Type: application/json" \
-d '{...}'Cause: CDP authentication didn't complete.
Solution:
- Check CDP Project ID in
.env - Verify domain is configured in CDP Portal
- Check browser console for CDP errors
Cause: Challenge expired or was already used.
Solution:
- Ensure backend challenge TTL is reasonable (5 minutes)
- Check that challenge is deleted after use
Cause: Message doesn't match or signature is invalid.
Solution:
- Ensure message is exactly the same on frontend and backend
- Check that wallet address is normalized to lowercase
- Verify signature format (0x prefix)
Cause: React 19 compatibility with CDP packages.
Solution:
- Use
--legacy-peer-depsflag when installing - This is expected and doesn't affect functionality
[Your License Here]
Contributions are welcome! Please read our contributing guidelines before submitting PRs.
For issues and questions:
- Create an issue on GitHub
- Contact: your-email@example.com
Built with β€οΈ using CDP + Wagmi