Skip to content

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

VariableDescriptionRequired
JWT_SECRETSecret for JWT signing (min 32 chars)Yes

Token Lifecycle

EventAction
User signs inToken stored in sessionStorage
API requestToken sent in Authorization header
Token expiresUser must sign again (24 hours)
Tab closessessionStorage cleared
User disconnects walletToken cleared manually

Error Handling

ErrorCauseAction
Missing Authorization headerNo token sentPrompt sign-in
Invalid or expired tokenToken expired or tamperedPrompt sign-in
Invalid nonceReplay attack or expiredRequest new nonce
Signature mismatchWrong account signedUse correct account

Security Considerations

  1. No password storage — Auth via cryptographic signature
  2. Stateless nonces — No server-side nonce storage needed
  3. Short expiry — Nonces expire in 5 minutes
  4. JWT expiry — Tokens expire in 24 hours
  5. Address verification — Signature proves wallet ownership

Fetch Quests — Decentralized Gig-Work Platform