Authentication (SIWE)
Sign-In With Ethereum authentication implementation guide for KhipuVault API.
Authentication with SIWE
KhipuVault uses SIWE (Sign-In With Ethereum) for secure, wallet-based authentication. No passwords required - users authenticate by signing a message with their Ethereum wallet.
Why SIWE?
- Self-custodial: Users control their own private keys
- No passwords: No password storage or leaks
- Crypto-native: Natural for Web3 users
- Secure: Cryptographic signature verification
- Standardized: Based on EIP-4361
Authentication Flow
┌──────────┐ ┌─────────┐ ┌──────────┐
│ Client │ │ API │ │ Wallet │
└────┬─────┘ └────┬────┘ └────┬─────┘
│ │ │
│ 1. Request nonce │ │
├──────────────────────────────>│ │
│ │ │
│ 2. Generate & return nonce │ │
│<──────────────────────────────┤ │
│ │ │
│ 3. Create SIWE message │ │
│ │ │
│ 4. Request signature │ │
├─────────────────────────────────────────────────────────────>│
│ │ │
│ │ 5. User approves │
│ │ │
│ 6. Return signature │ │
│<─────────────────────────────────────────────────────────────┤
│ │ │
│ 7. Send message + signature │ │
├──────────────────────────────>│ │
│ │ │
│ │ 8. Verify signature │
│ │ 9. Check nonce │
│ │ 10. Generate JWT │
│ │ │
│ 11. Return JWT token │ │
│<──────────────────────────────┤ │
│ │ │
│ 12. Use JWT for API calls │ │
├──────────────────────────────>│ │Frontend Implementation
1. Request Nonce
async function getNonce(address: string): Promise<string> {
const response = await fetch('https://api.khipuvault.com/auth/nonce', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address })
})
const { data } = await response.json()
return data.nonce
}2. Create SIWE Message
import { SiweMessage } from 'siwe'
async function createSiweMessage(
address: string,
nonce: string
): Promise<string> {
const message = new SiweMessage({
domain: window.location.host,
address,
statement: 'Sign in to KhipuVault',
uri: window.location.origin,
version: '1',
chainId: 31611, // Mezo Testnet
nonce,
issuedAt: new Date().toISOString()
})
return message.prepareMessage()
}3. Sign Message with Wallet
Using Wagmi:
import { useSignMessage } from 'wagmi'
function SignInButton() {
const { address } = useAccount()
const { signMessageAsync } = useSignMessage()
const handleSignIn = async () => {
if (!address) return
// 1. Get nonce
const nonce = await getNonce(address)
// 2. Create SIWE message
const message = await createSiweMessage(address, nonce)
// 3. Sign message
const signature = await signMessageAsync({ message })
// 4. Verify and get token
const token = await verifySignature(message, signature)
// 5. Store token
localStorage.setItem('khipu_token', token)
}
return <button onClick={handleSignIn}>Sign In</button>
}Using ethers.js (alternative):
import { BrowserProvider } from 'ethers'
async function signMessage(message: string): Promise<string> {
const provider = new BrowserProvider(window.ethereum)
const signer = await provider.getSigner()
const signature = await signer.signMessage(message)
return signature
}4. Verify Signature & Get JWT
async function verifySignature(
message: string,
signature: string
): Promise<string> {
const response = await fetch('https://api.khipuvault.com/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, signature })
})
if (!response.ok) {
throw new Error('Authentication failed')
}
const { data } = await response.json()
return data.token
}5. Use JWT for API Calls
async function fetchUserPools(token: string) {
const response = await fetch('https://api.khipuvault.com/pools', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
const { data } = await response.json()
return data
}Complete React Example
import { useState } from 'react'
import { useAccount, useSignMessage } from 'wagmi'
import { SiweMessage } from 'siwe'
export function useAuth() {
const { address } = useAccount()
const { signMessageAsync } = useSignMessage()
const [token, setToken] = useState<string | null>(
localStorage.getItem('khipu_token')
)
const signIn = async () => {
if (!address) throw new Error('No wallet connected')
try {
// 1. Get nonce
const nonceRes = await fetch('/auth/nonce', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address })
})
const { data: { nonce } } = await nonceRes.json()
// 2. Create SIWE message
const message = new SiweMessage({
domain: window.location.host,
address,
statement: 'Sign in to KhipuVault',
uri: window.location.origin,
version: '1',
chainId: 31611,
nonce,
issuedAt: new Date().toISOString()
}).prepareMessage()
// 3. Sign message
const signature = await signMessageAsync({ message })
// 4. Verify and get token
const verifyRes = await fetch('/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, signature })
})
const { data: { token } } = await verifyRes.json()
// 5. Store token
localStorage.setItem('khipu_token', token)
setToken(token)
return token
} catch (error) {
console.error('Sign in failed:', error)
throw error
}
}
const signOut = () => {
localStorage.removeItem('khipu_token')
setToken(null)
}
return { token, signIn, signOut, isAuthenticated: !!token }
}
// Usage
function App() {
const { token, signIn, signOut, isAuthenticated } = useAuth()
if (!isAuthenticated) {
return <button onClick={signIn}>Sign In with Wallet</button>
}
return (
<div>
<p>Authenticated!</p>
<button onClick={signOut}>Sign Out</button>
</div>
)
}Backend Implementation
1. Generate Nonce Endpoint
import crypto from 'crypto'
import { Router } from 'express'
const router = Router()
// In-memory store (use Redis in production)
const nonces = new Map<string, number>()
router.post('/nonce', (req, res) => {
const nonce = crypto.randomBytes(16).toString('hex')
// Store with 10-minute expiry
nonces.set(nonce, Date.now() + 10 * 60 * 1000)
res.json({
success: true,
data: { nonce }
})
})2. Verify Signature Endpoint
import { SiweMessage } from 'siwe'
import jwt from 'jsonwebtoken'
import { prisma } from '@khipu/database'
router.post('/verify', async (req, res) => {
try {
const { message, signature } = req.body
// 1. Parse SIWE message
const siweMessage = new SiweMessage(message)
// 2. Verify signature
const fields = await siweMessage.verify({ signature })
// 3. Check nonce hasn't been used
const nonceExpiry = nonces.get(siweMessage.nonce)
if (!nonceExpiry || Date.now() > nonceExpiry) {
return res.status(401).json({
success: false,
error: 'Invalid or expired nonce'
})
}
// 4. Delete nonce (prevent replay)
nonces.delete(siweMessage.nonce)
// 5. Get or create user
let user = await prisma.user.findUnique({
where: { address: siweMessage.address.toLowerCase() }
})
if (!user) {
user = await prisma.user.create({
data: { address: siweMessage.address.toLowerCase() }
})
}
// 6. Generate JWT
const token = jwt.sign(
{
userId: user.id,
address: user.address
},
process.env.JWT_SECRET!,
{ expiresIn: '7d' }
)
res.json({
success: true,
data: { token, user }
})
} catch (error) {
console.error('Verification failed:', error)
res.status(401).json({
success: false,
error: 'Authentication failed'
})
}
})3. Auth Middleware
import jwt from 'jsonwebtoken'
export const authMiddleware = (req, res, next) => {
try {
const token = req.headers.authorization?.replace('Bearer ', '')
if (!token) {
return res.status(401).json({
success: false,
error: 'No token provided'
})
}
const decoded = jwt.verify(token, process.env.JWT_SECRET!)
req.user = decoded
next()
} catch (error) {
res.status(401).json({
success: false,
error: 'Invalid token'
})
}
}
// Usage
app.get('/pools', authMiddleware, async (req, res) => {
const { userId } = req.user
// ... fetch pools for user
})Security Best Practices
Nonce Management
- Generate cryptographically secure nonces (crypto.randomBytes)
- Store with short expiry (5-10 minutes)
- Delete after use (prevent replay attacks)
- Use Redis for production (in-memory doesn't scale)
JWT Tokens
- Use strong secret (256+ bits)
- Set reasonable expiry (7-30 days)
- Include only necessary claims
- Validate on every request
HTTPS Only
// Enforce HTTPS in production
if (process.env.NODE_ENV === 'production' && req.protocol !== 'https') {
return res.redirect('https://' + req.get('host') + req.url)
}CORS Configuration
import cors from 'cors'
app.use(cors({
origin: process.env.FRONTEND_URL,
credentials: true
}))Token Refresh (Optional)
Implement refresh tokens for better UX:
// Generate both access and refresh tokens
const accessToken = jwt.sign({ userId }, SECRET, { expiresIn: '15m' })
const refreshToken = jwt.sign({ userId }, REFRESH_SECRET, { expiresIn: '7d' })
// Store refresh token in database
await prisma.refreshToken.create({
data: { userId, token: refreshToken }
})
// Refresh endpoint
router.post('/refresh', async (req, res) => {
const { refreshToken } = req.body
const decoded = jwt.verify(refreshToken, REFRESH_SECRET)
const stored = await prisma.refreshToken.findFirst({
where: { userId: decoded.userId, token: refreshToken }
})
if (!stored) {
return res.status(401).json({ error: 'Invalid refresh token' })
}
const accessToken = jwt.sign({ userId: decoded.userId }, SECRET, {
expiresIn: '15m'
})
res.json({ token: accessToken })
})Testing
import { SiweMessage } from 'siwe'
import { Wallet } from 'ethers'
describe('SIWE Authentication', () => {
it('should authenticate with valid signature', async () => {
// 1. Generate wallet
const wallet = Wallet.createRandom()
// 2. Get nonce
const { nonce } = await getNonce(wallet.address)
// 3. Create and sign message
const message = new SiweMessage({
domain: 'localhost',
address: wallet.address,
uri: 'http://localhost:3000',
version: '1',
chainId: 31611,
nonce
}).prepareMessage()
const signature = await wallet.signMessage(message)
// 4. Verify
const { token } = await verifySignature(message, signature)
expect(token).toBeDefined()
})
})Common Issues
"Invalid signature"
- Ensure message format matches exactly
- Check chainId matches network
- Verify domain and URI are correct
"Nonce expired"
- Nonce has 10-minute TTL
- Request new nonce if expired
"CORS error"
- Add frontend URL to CORS whitelist
- Include credentials: true
Next Steps
Auth Issues? Email dev@khipuvault.com or Discord #api