RoadToChain Logo
RoadToChain
T2/M2.3/Building the Express proxy layer for IPFS uploads
beginner 15m read

Building the Express proxy layer for IPFS uploads

The complete Node.js/Express backend that proxies IPFS uploads, validates sessions, and keeps API keys server-side only.

#express #node #ipfs #proxy #backend

Once I knew that API keys couldn't live in the frontend, I needed to build the backend fast.

The design goal was simple: the frontend should be able to upload files to IPFS without ever knowing the Pinata API key exists.

Here is exactly how I built it in ChainElect.


1. The Architecture Before and After

Before (broken):

SmartAccount.sol
React ──── Pinata API key in JS bundle ────► Pinata
                                              IPFS pin created

After (secure):

SmartAccount.sol
React ──── POST /api/upload (with session cookie) ────► Express Server
                                                              │
                                                    Validate session
                                                    (isAuthenticated middleware)
                                                              │
                                                    Read PINATA_KEY from .env
                                                    (server process only)
                                                              │
                                                    Forward to Pinata API
                                                              │
                                                    Return CID to React
                                                              │
React ◄──────────────────────────── { cid: "QmXyZ..." }
Secure IPFS Upload Proxy Flow
By routing media uploads through a backend proxy, API keys remain hidden on the server, and the backend can rate-limit uploads and validate user sessions before paying for IPFS storage.

2. The Backend Setup: Express + Multer + Supabase

ChainElect's backend is a standard Express server with three key dependencies:

package.json
json
// context/ChainElectBackend/package.json
{
  "dependencies": {
    "express": "^4.18.2",
    "multer": "^1.4.5-lts.1",     // multipart file handling
    "express-session": "^1.17.3",  // session management
    "cors": "^2.8.5",              // CORS for React frontend
    "@supabase/supabase-js": "^2"  // user auth and DB
  }
}

The server entry point sets up sessions, CORS, and mounts the routes:

index.js
javascript
// context/ChainElectBackend/server.js (structure)
const express = require('express');
const session = require('express-session');
const cors = require('cors');
const multer = require('multer');
 
const app = express();
 
// CORS — allow React frontend
app.use(cors({
  origin: process.env.FRONTEND_URL || 'http://localhost:5173',
  credentials: true // required for cookies/sessions
}));
 
// Session management — stores userId after login
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true, // prevents JS from reading the cookie
    maxAge: 24 * 60 * 60 * 1000 // 24 hours
  }
}));

3. The IPFS Upload Endpoint

api.pinata.cloud
javascript
// The secure upload endpoint (from server.js)
const upload = multer({ 
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 } // 5MB limit
});
 
app.post('/api/upload-image', isAuthenticated, upload.single('image'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ error: 'No file provided' });
    }
 
    // Build multipart form for Pinata
    const FormData = require('form-data');
    const axios = require('axios');
    const formData = new FormData();
    
    formData.append('file', req.file.buffer, {
      filename: req.file.originalname,
      contentType: req.file.mimetype
    });
 
    formData.append('pinataMetadata', JSON.stringify({
      name: `chainalect-voter-${req.session.userId}-${Date.now()}`
    }));
 
    // API key lives here — server process only, never sent to browser
    const response = await axios.post(
      'https://api.pinata.cloud/pinning/pinFileToIPFS',
      formData,
      {
        headers: {
          ...formData.getHeaders(),
          Authorization: `Bearer ${process.env.PINATA_JWT}` // ← server .env only
        },
        maxBodyLength: Infinity
      }
    );
 
    // Return ONLY the CID — no secrets
    res.json({ 
      success: true,
      cid: response.data.IpfsHash,
      url: `https://gateway.pinata.cloud/ipfs/${response.data.IpfsHash}`
    });
 
  } catch (error) {
    console.error('Upload error:', error.response?.data || error.message);
    res.status(500).json({ error: 'Upload failed' });
  }
});

4. The Frontend Side: Calling the Proxy

The React frontend calls the backend endpoint — completely unaware of Pinata:

index.js
javascript
// context/src/pages/Register.jsx (simplified)
const uploadProfileImage = async (file) => {
  const formData = new FormData();
  formData.append('image', file);
 
  const response = await fetch(`${API_URL}/api/upload-image`, {
    method: 'POST',
    credentials: 'include', // send session cookie
    body: formData
    // Notice: NO Authorization header needed in the frontend
  });
 
  const data = await response.json();
  return data.cid; // just the CID
};

The frontend now knows nothing about Pinata, its API key, or any other infrastructure detail. It gets a CID and stores that CID reference in the contract.


// Reality Check

The Express backend introduced here is intentionally minimal — it's an auth and proxy layer, not a data store. All persistent data (voter registrations, vote results) remains on-chain. The backend's database (Supabase) only stores the user's email, session data, and profile metadata — information that requires traditional auth and is not suitable for on-chain storage.

— Production Engineering Principle

System Design Challenge
Think Active

Study context/ChainElectBackend/server.js. Find the isAuthenticated middleware. Trace what happens if a request arrives without a valid session: what HTTP status code is returned? Now consider: what would happen if an attacker tried to upload 10,000 files per minute by calling /api/upload-image directly? What's missing from the current backend that would prevent this?

[ Think Before Continuing ]

Was this lesson helpful?

Let us know what you think of this specification. (submitting anonymously)