This document explains how to use the SIWE authentication system in ZK API.
ZK API implements a minimalistic SIWE authentication system using NestJS Guards that allows users to authenticate using their Ethereum wallet. The system uses:
- Guard-based authentication - NestJS Guards validate SIWE signatures on protected endpoints
- Header-based credentials - SIWE message and signature sent via HTTP headers
- Stateless nonce-based authentication - No JWT tokens, no persistent sessions
- In-memory nonce storage - Ephemeral, TEE-friendly (no data persistence)
- 5-minute time window - Nonces expire after 5 minutes
- Single-use nonces - Each nonce can only be used once
Endpoint: POST /auth/nonce
Generates a cryptographically secure random nonce that must be included in the SIWE message.
Request:
curl -k -X POST https://localhost:3000/auth/nonceResponse:
{
"nonce": "d4c595490e15489574ca06494154cbedd156db6629224481221c04f83ac32d9e",
"issuedAt": "2026-03-17T16:00:00.000Z",
"expiresAt": "2026-03-17T16:05:00.000Z"
}Protected endpoints require SIWE authentication via HTTP headers. The SiweGuard automatically validates credentials.
Example: Protected Hello Endpoint
Endpoint: POST /hello
Request:
curl -k -X POST https://localhost:3000/hello \
-H 'x-siwe-message: localhost wants you to sign in with your Ethereum account:
0xYourAddress
URI: https://localhost:3000
Version: 1
Chain ID: 1
Nonce: your-nonce-here
Issued At: 2026-03-17T16:49:38.495Z' \
-H 'x-siwe-signature: 0x...'Response (Success - 200):
{
"message": "Hello, authenticated user!",
"address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
}Response (Unauthorized - 401):
{
"statusCode": 401,
"message": "Invalid SIWE signature or expired nonce"
}Note: The SIWE message in the header should be properly formatted (use literal newlines or escape them depending on your HTTP client).
┌─────────┐ ┌─────────────┐ ┌─────────┐
│ Client │ │ ZK API │ │ Wallet │
└────┬────┘ └──────┬──────┘ └────┬────┘
│ │ │
│ 1. POST /auth/nonce │ │
│───────────────────────────────>│ │
│ │ │
│ 2. {nonce, issuedAt, ...} │ │
│<───────────────────────────────│ │
│ │ │
│ 3. Create SIWE message │ │
│ with nonce │ │
│────────────────────────────────┼───────────────────────────────>│
│ │ │
│ 4. Sign message │ │
│<───────────────────────────────┼────────────────────────────────│
│ │ │
│ 5. POST /hello (or other │ │
│ protected endpoint) │ │
│ Headers: │ │
│ x-siwe-message: ... │ │
│ x-siwe-signature: ... │ │
│───────────────────────────────>│ │
│ │ │
│ │ 6. SiweGuard intercepts │
│ │ 7. Verify signature │
│ │ 8. Check nonce validity │
│ │ 9. Delete nonce (single-use) │
│ │ 10. Attach address to request │
│ │ │
│ 11. Response with address │ │
│<───────────────────────────────│ │
│ │ │
Note: Any endpoint protected with @UseGuards(SiweGuard) can be accessed this way.
- Install dependencies:
npm install siwe ethers- Complete example:
import { SiweMessage } from 'siwe';
import { Wallet } from 'ethers';
// Your wallet
const wallet = new Wallet('0x...');
// Step 1: Get nonce from server
const nonceResponse = await fetch('https://localhost:3000/auth/nonce', {
method: 'POST',
});
const { nonce } = await nonceResponse.json();
// Step 2: Create SIWE message
const siweMessage = new SiweMessage({
domain: 'localhost',
address: wallet.address,
uri: 'https://localhost:3000',
version: '1',
chainId: 1,
nonce: nonce,
issuedAt: new Date().toISOString(),
});
const message = siweMessage.prepareMessage();
// Step 3: Sign the message
const signature = await wallet.signMessage(message);
// Step 4: Access protected endpoint with headers
const response = await fetch('https://localhost:3000/hello', {
method: 'POST',
headers: {
'x-siwe-message': message,
'x-siwe-signature': signature,
},
});
const result = await response.json();
console.log(result); // { message: "Hello, authenticated user!", address: "0x..." }w3pk is a WebAuthn-based wallet SDK with native SIWE support. It provides passwordless authentication using biometrics.
import { createWeb3Passkey } from 'w3pk';
// Initialize w3pk
const w3pk = createWeb3Passkey();
// Register or login user (triggers biometric prompt)
await w3pk.register({ username: '[email protected]' });
// or
await w3pk.login();
// Step 1: Get nonce from ZK API
const nonceResponse = await fetch('https://localhost:3000/auth/nonce', {
method: 'POST',
});
const { nonce } = await nonceResponse.json();
// Step 2: Get user's address
const address = w3pk.user?.address;
// Step 3: Create SIWE message
const siweMessage = `${window.location.host} wants you to sign in with your Ethereum account:
${address}
URI: ${window.location.origin}
Version: 1
Chain ID: 1
Nonce: ${nonce}
Issued At: ${new Date().toISOString()}`;
// Step 4: Sign with w3pk using SIWE method (triggers biometric prompt)
const { signature } = await w3pk.signMessage(siweMessage, {
signingMethod: 'SIWE' // EIP-4361 compliant
});
// Step 5: Access protected endpoint with ZK API
const response = await fetch('https://localhost:3000/hello', {
method: 'POST',
headers: {
'x-siwe-message': siweMessage,
'x-siwe-signature': signature,
},
});
const result = await response.json();
console.log(result); // { message: "Hello, authenticated user!", address: "0x..." }Benefits of w3pk:
- ✅ No seed phrases or passwords - uses device biometrics
- ✅ Native SIWE support with
signingMethod: 'SIWE' - ✅ Non-custodial - keys secured by WebAuthn
- ✅ Works across devices with passkey sync
- ✅ Session management built-in
// Step 1: Get nonce
const nonceResponse = await fetch('https://localhost:3000/auth/nonce', {
method: 'POST',
});
const { nonce } = await nonceResponse.json();
// Step 2: Create SIWE message
const siweMessage = new SiweMessage({
domain: window.location.host,
address: ethereum.selectedAddress,
uri: window.location.origin,
version: '1',
chainId: 1,
nonce: nonce,
issuedAt: new Date().toISOString(),
});
const message = siweMessage.prepareMessage();
// Step 3: Request signature from MetaMask
const signature = await ethereum.request({
method: 'personal_sign',
params: [message, ethereum.selectedAddress],
});
// Step 4: Access protected endpoint with headers
const response = await fetch('https://localhost:3000/hello', {
method: 'POST',
headers: {
'x-siwe-message': message,
'x-siwe-signature': signature,
},
});
const result = await response.json();
console.log(result);Step 1: Get a fresh nonce
curl -k -X POST https://localhost:3000/auth/nonceResponse:
{
"nonce": "d4c595490e15489574ca06494154cbedd156db6629224481221c04f83ac32d9e",
"issuedAt": "2026-03-17T16:00:00.000Z",
"expiresAt": "2026-03-17T16:05:00.000Z"
}Step 2: Create the SIWE message (using the siwe library or manually)
Use the exact format with your EIP-55 checksummed address:
localhost wants you to sign in with your Ethereum account:
0xYourChecksummedAddress
URI: https://localhost:3000
Version: 1
Chain ID: 1
Nonce: d4c595490e15489574ca06494154cbedd156db6629224481221c04f83ac32d9e
Issued At: 2026-03-17T16:00:00.000Z
Step 3: Sign with Etherscan
- Go to https://etherscan.io/verifiedSignatures
- Click "Sign Message"
- Paste the complete SIWE message from Step 2
- Connect your wallet and sign
Step 4: Access protected endpoint
curl -k -X POST https://localhost:3000/hello \
-H 'x-siwe-message: localhost wants you to sign in with your Ethereum account:
0xYourChecksummedAddress
URI: https://localhost:3000
Version: 1
Chain ID: 1
Nonce: d4c595490e15489574ca06494154cbedd156db6629224481221c04f83ac32d9e
Issued At: 2026-03-17T16:00:00.000Z' \
-H 'x-siwe-signature: 0xYourSignatureFromEtherscan...'The SIWE message must follow this exact format:
localhost wants you to sign in with your Ethereum account:
0xYourEthereumAddress
URI: https://localhost:3000
Version: 1
Chain ID: 1
Nonce: your-nonce-here
Issued At: 2026-03-17T16:49:38.495Z
Important notes:
- There are three newlines after the Ethereum address (
\n\n\n) - The message format is case-sensitive
- All fields must be present in this exact order
- The
Issued Attimestamp must match theissuedAtfrom the nonce response - The Ethereum address must be EIP-55 checksummed (correct capitalization)
When sending via JSON, escape the newlines:
{
"message": "localhost wants you to sign in with your Ethereum account:\n0xAddress\n\n\nURI: https://localhost:3000\nVersion: 1\nChain ID: 1\nNonce: abc\nIssued At: 2026-03-17T16:49:38.495Z"
}Possible causes:
- Missing headers -
x-siwe-messageandx-siwe-signatureheaders are required - Expired nonce - Nonces expire after 5 minutes
- Used nonce - Each nonce can only be used once
- Invalid signature - The signature doesn't match the message
- Message format mismatch - The signed message doesn't exactly match the format
Solution: Always get a fresh nonce and sign it immediately, then include both headers in your request.
Cause: Different HTTP clients handle newlines differently in headers.
Solution:
- In curl, use literal newlines in single-quoted headers
- In JavaScript fetch, include literal newlines in the string
- If your client doesn't support multi-line headers, you may need to escape them as
\n
✅ Correct (curl):
curl -H 'x-siwe-message: line 1
line 2'✅ Correct (JavaScript):
headers: {
'x-siwe-message': 'line 1\nline 2'
}Cause: SIWE requires three newlines (\n\n\n) after the address, not two.
Solution: Use the siwe library's prepareMessage() method to generate the correct format.
Cause: Reusing a nonce from a previous test or example.
Solution: Always call POST /auth/nonce to get a fresh nonce before each test.
Cause: The Ethereum address doesn't have the correct checksum capitalization.
Example of incorrect address:
- ❌
0x502fb0dff6a2adbf43468c9888d1a26943eac6d1(all lowercase) - ✅
0x502fb0dFf6A2adbF43468C9888D1A26943eAC6D1(checksummed)
Solution: Use a checksummed address. You can:
- Get it from your wallet (MetaMask, etc.)
- Use Etherscan to look up your address
- Use
ethers.getAddress()to checksum it:
import { getAddress } from 'ethers';
const checksummed = getAddress('0x502fb0dff6a2adbf43468c9888d1a26943eac6d1');
// Returns: 0x502fb0dFf6A2adbF43468C9888D1A26943eAC6D1Cause: The Issued At timestamp in the SIWE message doesn't match the issuedAt from the nonce response.
Solution: Use the exact issuedAt value from the nonce response in your SIWE message. Don't use new Date().toISOString() or create your own timestamp.
This SIWE implementation is designed for TEE environments:
- No persistent storage - Nonces are stored in-memory only
- No JWT secrets - No shared secrets that could be extracted
- Ephemeral by design - Server restart clears all nonces
- No session tracking - Each verification is independent
- Nonces are single-use - Deleted immediately after verification
- Nonces expire after 5 minutes - Time-limited window
- Nonces are cryptographically random - 32 bytes (256 bits) of entropy
- Expired nonces are automatically cleaned up - Prevents memory bloat
- Signatures are verified using
siwelibrary's built-in verification - The recovered address must match the address in the SIWE message
- Verification failures return no information about the failure reason (security by obscurity)
Protected endpoints (including those with SiweGuard) and /auth/nonce are protected by the global rate limiter:
- 10 requests per minute per IP address
- Prevents brute-force attacks and DoS
SiweGuard- NestJS Guard that implementsCanActivate- Extracts credentials from
x-siwe-messageandx-siwe-signatureheaders - Verifies signature using SIWE library
- Validates nonce (existence, expiration, single-use)
- Attaches verified address to
request.user.address - Throws
UnauthorizedException(401) on any validation failure
Usage in controllers:
import { UseGuards, Request } from '@nestjs/common';
import { SiweGuard } from './auth/siwe.guard';
@UseGuards(SiweGuard)
@Post('protected')
async protectedEndpoint(@Request() req) {
// req.user.address contains the verified Ethereum address
return { address: req.user.address };
}Run the test suite:
pnpm testThe tests verify:
- SiweService and SiweGuard are properly injected
- SiweGuard throws
UnauthorizedExceptionwhen headers are missing - SiweGuard throws
UnauthorizedExceptionfor invalid signatures - Protected endpoints return authenticated address when valid
- Nonce generation works correctly
- Nonce expiration is enforced
The SIWE endpoints are documented in the Swagger UI at:
https://localhost:3000
You can test the endpoints directly from the browser using the interactive API documentation.
Typical response times on a TEE-enabled server:
POST /auth/nonce: ~10msPOST /hello: ~50-100ms (signature verification is CPU-intensive)
In production, update the SIWE message to use your actual domain:
const siweMessage = new SiweMessage({
domain: 'your-domain.com', // Update this
address: wallet.address,
uri: 'https://your-domain.com', // Update this
version: '1',
chainId: 1, // Or your desired chain ID
nonce: nonce,
issuedAt: new Date().toISOString(),
});Update CORS settings in src/main.ts to allow your frontend domain:
app.enableCors({
origin: 'https://your-frontend.com', // Update in production
credentials: true,
});The server uses HTTPS with TLS termination inside the TEE. In production:
- Certificates are generated inside the enclave
- Private keys never leave the enclave
- Verify the attestation report before sending sensitive data
The default chain ID is 1 (Ethereum Mainnet). Update this based on your needs:
1- Ethereum Mainnet11155111- Sepolia Testnet10- Optimism137- Polygon- etc.