Authentication
Fetch Quests uses Sign-In with Ethereum (SIWE) for passwordless authentication.
Flow Overview
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Wallet │────▶│ /api/ │────▶│ Frontend │────▶│ /api/ │
│ (address) │ │auth/nonce │ │ (sign) │ │auth/verify │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ JWT │
│ Token │
└─────────────┘Step-by-Step
1. Request Nonce
typescript
// Frontend
const { nonce, nonceToken } = await getNonce(account);javascript
// api/auth/nonce.js
export default async function handler(req, res) {
const { address } = req.query;
const { nonce, nonceToken } = createNonce(address);
res.json({ nonce, nonceToken });
}The nonce is a random string + HMAC signature to prevent replay attacks.
2. Sign Message
typescript
// Frontend
const message = [
'Fetch Quests wants you to sign in with your Ethereum account:',
account,
'',
'Sign in to Fetch Quests',
'',
`URI: ${window.location.origin}`,
'Version: 1',
`Chain ID: ${SUPPORTED_CHAIN_ID}`,
`Nonce: ${nonce}`,
`Issued At: ${new Date().toISOString()}`,
].join('\n');
const signature = await signMessageAsync({ message });This prompts MetaMask to sign the message (no gas required).
3. Verify Signature
typescript
// Frontend
const token = await verifySignature(account, signature, nonce, nonceToken, message);javascript
// api/auth/verify.js
export default async function handler(req, res) {
const { address, signature, nonce, nonceToken, message } = req.body;
// Verify nonce token (HMAC)
if (!verifyNonce(address, nonce, nonceToken)) {
return res.status(401).json({ error: 'Invalid nonce' });
}
// Verify signature (recover address)
const recovered = await recoverMessageAddress({ message, signature });
if (recovered.toLowerCase() !== address.toLowerCase()) {
return res.status(401).json({ error: 'Signature mismatch' });
}
// Issue JWT
const token = await signJwt(address);
res.json({ token });
}4. Store Token
typescript
// Frontend
_setAuthToken(token);
sessionStorage.setItem('fq_authToken', token);JWT Structure
javascript
// Header
{ "alg": "HS256", "typ": "JWT" }
// Payload
{
"address": "0x1234...", // Lowercase
"iat": 1709312400, // Issued at
"exp": 1709398800 // Expires (24 hours)
}Using Auth in API Requests
typescript
// Frontend
const response = await fetch('/api/profile/0x123', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ githubUsername: 'myuser' }),
});Backend Auth Middleware
javascript
// api/_lib/auth.js
export async function requireAuth(req, res) {
const auth = req.headers.authorization ?? '';
if (!auth.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing Authorization header' });
return null;
}
const payload = await verifyJwt(auth.slice(7));
if (!payload) {
res.status(401).json({ error: 'Invalid or expired token' });
return null;
}
return payload.address; // Returns lowercase address
}
// Usage in endpoint
export default async function handler(req, res) {
const address = await requireAuth(req, res);
if (!address) return; // Already sent 401
// User is authenticated, address is verified
}Nonce Implementation
Nonces are stateless using HMAC tokens:
javascript
function createNonce(address) {
const nonce = crypto.randomBytes(16).toString('hex');
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
const payload = Buffer.from(JSON.stringify({
a: address.toLowerCase(),
n: nonce,
e: expiresAt,
})).toString('base64url');
const sig = createHmac('sha256', JWT_SECRET).update(payload).digest('base64url');
return { nonce, nonceToken: `${payload}.${sig}` };
}
function verifyNonce(address, nonce, nonceToken) {
const [payload, sig] = nonceToken.split('.');
// Verify HMAC
const expected = createHmac('sha256', JWT_SECRET).update(payload).digest('base64url');
if (sig !== expected) return false;
// Parse and validate
const data = JSON.parse(Buffer.from(payload, 'base64url').toString());
if (data.a !== address.toLowerCase()) return false;
if (data.n !== nonce) return false;
if (Date.now() > data.e) return false;
return true;
}Environment Variables
| Variable | Description | Required |
|---|---|---|
JWT_SECRET | Secret for JWT signing (min 32 chars) | Yes |
Token Lifecycle
| Event | Action |
|---|---|
| User signs in | Token stored in sessionStorage |
| API request | Token sent in Authorization header |
| Token expires | User must sign again (24 hours) |
| Tab closes | sessionStorage cleared |
| User disconnects wallet | Token cleared manually |
Error Handling
| Error | Cause | Action |
|---|---|---|
Missing Authorization header | No token sent | Prompt sign-in |
Invalid or expired token | Token expired or tampered | Prompt sign-in |
Invalid nonce | Replay attack or expired | Request new nonce |
Signature mismatch | Wrong account signed | Use correct account |
Security Considerations
- No password storage — Auth via cryptographic signature
- Stateless nonces — No server-side nonce storage needed
- Short expiry — Nonces expire in 5 minutes
- JWT expiry — Tokens expire in 24 hours
- Address verification — Signature proves wallet ownership