Building with Passport
Stamps API
Passport Embed
tutorial
Protecting sensitive programs with Passport Embed

Protecting Sensitive Programs with Passport Embed

This demo application showcases how to gate sensitive content (a protected image in this case) behind Human Passport's embed widget. It uses a Vite + React frontend with Reown AppKit for wallet connection and the Passport Embed component for Passport score checking. An Express backend securely verifies the Passport score via Passport's API before releasing the protected content. The UI also automatically switches between light and dark themes based on the user's system preference.

Project Setup & Structure

Prerequisites: Node.js (>= v18 recommended), npm or Yarn.

Project structure:

passport-demo/
├── frontend/        # Vite + React app
│   ├── src/
│   │   ├── main.jsx
│   │   └── App.jsx
│   ├── index.html
│   └── package.json
├── backend/
│   ├── index.js     # Express server
│   └── secret.png   # Protected image file (put any image here)
└── .env             # Environment variables for both frontend and backend

We'll create a Vite React app for the frontend and a separate Express server for the backend. Below are all the required files and their contents.

Install Dependencies

First, initialize the React app and install dependencies:

# Create a new Vite React project (if not already created)
npm create vite@latest frontend -- --template react
 
cd frontend
 
# Install required packages
npm install @human-tech/passport-embed @reown/appkit @reown/appkit-adapter-wagmi wagmi viem @tanstack/react-query

This installs:

  • @human-tech/passport-embed – Passport Embed react component.
  • @reown/appkit and @reown/appkit-adapter-wagmi – Reown AppKit for wallet connection (with Wagmi integration).
  • wagmi and viem (and @tanstack/react-query) – for Ethereum wallet hooks and provider.

Environment Configuration

Create a file named .env in the project root and add the following variables (replace placeholders with your values):

# Passport API credentials (for Gitcoin Passport API)
VITE_PASSPORT_API_KEY=<YOUR_PASSPORT_API_KEY>
VITE_EMBED_API_KEY=<YOUR_EMBED_API_KEY>
VITE_PASSPORT_SCORER_ID=<YOUR_PASSPORT_SCORER_ID>
 
# Reown AppKit (WalletConnect) configuration
VITE_REOWN_PROJECT_ID=<YOUR_REOWN_PROJECT_ID>   # Project ID from Reown (WalletConnect) cloud
 
# Ethereum RPC URL (for wagmi provider, optional)
VITE_RPC_URL=https://rpc.ankr.com/eth   # Public Mainnet RPC (or use your own Infura/Alchemy URL)
  • VITE_PASSPORT_API_KEY: Your API key for the Passport API (get this from Passport Developer Portal (opens in a new tab)). This is used via the backend to verify that the user's Passport scores is above the passing threshold.
  • VITE_EMBED_API_KEY: Your API key for the Passport Embed component (get this from Passport Developer Portal (opens in a new tab)). This is used on the frontend by the Passport Embed component.
  • VITE_PASSPORT_SCORER_ID: The ID of the Passport Scorer to use, which you can generate via the Passport Developer Portal (opens in a new tab). You will set up your score threshold during the Scorer creation process.
  • VITE_REOWN_PROJECT_ID: A Project ID from Reown’s cloud dashboard for WalletConnect. You can obtain one at cloud.reown.com (Reown provides a demo projectId for localhost testing if needed).
  • VITE_RPC_URL: An Ethereum RPC endpoint for wagmi (used to initialize the provider). A default public RPC is provided, but you can replace it with your own.

Please Note: The VITE_ prefix on variables makes them available to the Vite frontend. The backend will also read these (or you can duplicate them without VITE_ prefix for clarity). Ensure not to expose your API key in production – in this demo we use it on the frontend for simplicity, but the secure verification happens on the backend.

Setting up the React application

frontend/src/main.jsx – Initialize Reown and Wagmi Providers

import React from 'react';
import ReactDOM from 'react-dom/client';
import { ReOwnProvider } from '@reown/appkit';               // Reown AppKit provider
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi';  // Reown Wagmi adapter
import { WagmiConfig } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
 
// Load config from environment
const projectId = import.meta.env.VITE_REOWN_PROJECT_ID;
const networks = [
  {
    id: 1, 
    token: 'ETH', 
    label: 'Ethereum', 
    rpcUrl: import.meta.env.VITE_RPC_URL || 'https://rpc.ankr.com/eth'
  }
];
 
// Create a Wagmi adapter (configures connectors for injected wallets, WalletConnect, etc.)
const wagmiAdapter = new WagmiAdapter({ networks, projectId });
// Set up React Query client (wagmi v1 uses react-query under the hood)
const queryClient = new QueryClient();
 
// Render the app with context providers
ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    {/* ReownProvider enables the wallet modal and social logins */}
    <ReOwnProvider adapters={[wagmiAdapter]} networks={networks} projectId={projectId}>
      {/* WagmiConfig provides web3 wallet hooks (using config from Reown adapter) */}
      <WagmiConfig config={wagmiAdapter.wagmiConfig}>
        {/* React Query provider for wagmi (caching) */}
        <QueryClientProvider client={queryClient}>
          <App />
        </QueryClientProvider>
      </WagmiConfig>
    </ReOwnProvider>
  </React.StrictMode>
);

Key points:

  • We use WagmiAdapter from Reown to automatically configure Ethereum wallet connectors (MetaMask, WalletConnect, Coinbase, etc.) using the provided projectId and network. The adapter’s wagmiConfig is passed into Wagmi.
  • ReOwnProvider wraps our app to handle the WalletConnect modal and any Reown-specific context. By including ReOwnProvider, we can use the <appkit-button> web component (or hooks) to trigger the wallet connection modal.
  • We configure one network (Ethereum mainnet, id 1) with an RPC URL. You could add others or use testnets as needed.
  • The QueryClientProvider is included because Reown/Wagmi uses React Query for managing async state.

frontend/src/App.jsx – Main App Component

This component handles the UI logic:

  • Connect wallet button (via Reown’s <appkit-button>).
  • Display of Passport score using the Passport Embed widget.
  • Client-side check of Passport score using the usePassportScore hook.
  • Triggering the backend verification once the score is passing.
  • Conditionally rendering the protected image upon successful verification.
import React, { useEffect, useState } from 'react';
import { useAccount } from 'wagmi';
import {
  usePassportScore,
  PassportScoreWidget,
  DarkTheme,
  LightTheme
} from '@human-tech/passport-embed';
 
function App() {
  const { address } = useAccount();  // get the connected wallet address (if any)
  const [backendStatus, setBackendStatus] = useState(null);  // "passed" or "failed" from backend
 
  // Determine system theme preference (for widget theming)
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
 
  // Use Passport hook to fetch score and passing status client-side
  const { score, isPassing, loading, error } = usePassportScore({
    apiKey: import.meta.env.VITE_EMBED_API_KEY,
    scorerId: import.meta.env.VITE_PASSPORT_SCORER_ID,
    address: address,
  });
 
  // Effect: when user has a passing score, call backend to verify securely
  useEffect(() => {
    if (isPassing && address) {
      // Trigger backend verification (fail-safe check)
      fetch('/verify-passport', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ address })
      })
        .then(res => res.json())
        .then(data => {
          setBackendStatus(data.status);  // "passed" or "failed"
        })
        .catch(err => {
          console.error('Failed to verify with backend:', err);
        });
    } else {
      setBackendStatus(null);
    }
  }, [isPassing, address]);
 
  // UI Rendering:
  if (!address) {
    // If wallet not connected, show Connect button
    return (
      <div className="container">
        <h2>Please Connect Your Wallet</h2>
        {/* Reown AppKit Web Component to open the wallet connection modal */}
        <appkit-button />
      </div>
    );
  }
 
  return (
    <div className="container">
      <h2>Connected as: <code>{address}</code></h2>
 
      {/* Passport Score Widget (displays the user's Passport score and stamps) */}
      <PassportScoreWidget 
        apiKey={import.meta.env.VITE_EMBED_API_KEY}
        scorerId={import.meta.env.VITE_PASSPORT_SCORER_ID}
        address={address}
        theme={prefersDark ? DarkTheme : LightTheme}
      />
 
      {/* Status text for Passport score */}
      {loading && <p>Loading Passport score...</p>}
      {error && <p style={{color: 'red'}}>Error loading Passport data: {error.message}</p>}
      {score !== undefined && !loading && (
        <p>
          Passport Score: <strong>{score}</strong>{" "}
          {isPassing ? <span style={{color: 'green'}}>✅ Score meets threshold!</span>
                    : <span style={{color: 'orange'}}>❌ Score below threshold</span>}
        </p>
      )}
 
      {/* Protected content section (shown only if backend verification passed) */}
      {backendStatus === 'passed' ? (
        <div className="protected-content">
          <h3>🎉 Access Granted!</h3>
          <p>You have a valid Passport score. Here is the protected content:</p>
          <img src={`/protected-image?address=${address}`} alt="Protected secret" style={{maxWidth: '100%'}} />
        </div>
      ) : backendStatus === 'failed' ? (
        <p style={{color: 'red'}}>
          Passport score did not meet the requirement. Please verify more credentials in your Passport to increase your score.
        </p>
      ) : (
        <p><em>Complete the Passport verification to unlock the protected content.</em></p>
      )}
    </div>
  );
}
 
export default App;

Notes on the frontend:

  • We use Reown’s useAccount (via wagmi) to get the currently connected wallet address. If no address is connected, we render a Connect Wallet prompt. The <appkit-button /> is a built-in Reown AppKit web component that triggers the wallet connection modal – when clicked, it allows the user to select a wallet (MetaMask, WalletConnect, etc.) and connect. Once connected, the useAccount hook will populate the address.
  • The Passport Embeds hook usePassportScore is initialized with the API key, scorer ID, and the user’s address. This hook automatically fetches the user’s latest Passport score from the Passport API and provides:
    • score: the numeric Passport score (sum of all stamp points).
    • isPassing: boolean indicating if the score meets the scorer’s threshold (e.g., >= 20 points).
    • loading and error: state for the fetch operation.
  • We also render a <PassportScoreWidget> component from the Passport SDK. This is an embeddable widget that visually displays the user’s Passport stamps and overall score. It uses the same apiKey, scorerId, and address. We pass a theme prop to it, choosing DarkTheme or LightTheme from the library based on the user’s system prefers-color-scheme. This means the widget will automatically style itself to match the user’s light or dark mode preference.
  • When the user’s score becomes passing (isPassing === true), a useEffect hook triggers a call to our backend (/verify-passport). We send the user’s address in the POST request. The backend will independently fetch the score from the Passport API (using a secure API key) and respond with a status. We store this status in state (backendStatus):
    • If data.status === "passed", it means the backend confirmed the score is high enough. We then display the protected image/content.
    • If not, we show a failure message.
  • The protected image is requested from a special endpoint /protected-image?address=.... Notice that we include the user’s address as a query parameter – our backend will use this to double-check the score on each image request as an extra security measure (so someone cannot just guess the image URL without passing the check).
  • Dynamic theming: By checking window.matchMedia('(prefers-color-scheme: dark)'), we decide which Passport theme to apply. The Passport widget will then render with appropriate colors. If the user’s system switches themes, a page refresh would update the widget theme (for a fully dynamic switch, one could add an event listener to update state on theme change).

Backend: Express Server for Secure Verification

Now, let’s set up the Express backend that will handle verification and serve the protected image. Make sure to install Express (and optionally node-fetch if using an earlier version of Node than 18):

# In the project root (or backend folder), initialize and install dependencies
cd ../backend
npm init -y
npm install express node-fetch dotenv

Create backend/index.js with the following content:

require('dotenv').config();
const path = require('path');
const express = require('express');
const fetch = require('node-fetch');  // (If on Node 18+, you can use global fetch instead)
const app = express();
 
app.use(express.json());
 
// POST /verify-passport – Verify user's Passport score via Passport API
app.post('/verify-passport', async (req, res) => {
  const { address } = req.body;
  if (!address) {
    return res.status(400).json({ error: 'Address is required' });
  }
  try {
    // Use API key and scorer ID from env (accept either VITE_ prefixed or not)
    const apiKey = process.env.PASSPORT_API_KEY || process.env.VITE_PASSPORT_API_KEY;
    const scorerId = process.env.PASSPORT_SCORER_ID || process.env.VITE_PASSPORT_SCORER_ID;
    const url = `https://api.passport.xyz/v2/stamps/${scorerId}/score/${address}`;
    
    // Call Passport API to get the latest score for this address【9†】
    const response = await fetch(url, { headers: { 'X-API-KEY': apiKey } });
    const data = await response.json();
    
    // Check if the score meets the passing threshold
    if (data.passing_score === true) {
      console.log(`✅ Passport score verified for ${address} (score: ${data.score})`);
      return res.json({ status: 'passed' });
    } else {
      console.log(`❌ Passport score too low for ${address} (score: ${data.score})`);
      return res.json({ status: 'failed' });
    }
  } catch (err) {
    console.error('Error verifying Passport score:', err);
    res.status(500).json({ error: 'Internal server error' });
  }
});
 
// GET /protected-image – Serve the protected image if the Passport score is verified
app.get('/protected-image', async (req, res) => {
  const address = req.query.address;
  if (!address) {
    return res.status(400).send('Address query parameter is required');
  }
  try {
    const apiKey = process.env.PASSPORT_API_KEY || process.env.VITE_PASSPORT_API_KEY;
    const scorerId = process.env.PASSPORT_SCORER_ID || process.env.VITE_PASSPORT_SCORER_ID;
    const url = `https://api.passport.xyz/v2/stamps/${scorerId}/score/${address}`;
    const response = await fetch(url, { headers: { 'X-API-KEY': apiKey } });
    const data = await response.json();
    
    if (data.passing_score === true) {
      // Address has a passing Passport score – send the secret image
      return res.sendFile(path.join(__dirname, 'secret.png'));
    } else {
      // Not allowed to access the image
      return res.status(403).send('Forbidden: Passport score too low');
    }
  } catch (err) {
    console.error('Error serving protected image:', err);
    res.status(500).send('Server error');
  }
});
 
// Start the server
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`✅ Passport backend running at http://localhost:${PORT}`);
});

Explanation:

  • The backend uses Passport’s public API to retrieve the user’s score server-side. We call the GET /v2/stamps/{scorer_id}/score/{address} endpoint with our API key in the header【9†】. This returns a JSON object containing the address’s overall score and a boolean passing_score field indicating if the score meets the defined threshold.
  • The /verify-passport endpoint expects an address in the POST body. It fetches the Passport score for that address and responds with { status: "passed" } or { status: "failed" }. This is the backend fail-safe – even if the frontend is tampered with, the sensitive content will only be unlocked if this check passes.
  • The /protected-image endpoint serves the actual image. It again verifies the Passport score for the given address (from the query parameter) before sending the file. This double-check ensures that even direct requests to the image URL require a valid score. If the score is insufficient, it returns HTTP 403 Forbidden.
  • Place your secret image file as backend/secret.png. This could be any image (for demo purposes, you can use a small placeholder). The Express code uses sendFile to serve this file when authorized. (Ensure the path is correct or adjust it if your file is in a different location.)
  • We use dotenv to load environment variables. The code looks for both PASSPORT_API_KEY and VITE_PASSPORT_API_KEY – this way, if you keep a single .env file with the VITE_ prefixes, it still finds them. Alternatively, you could define separate variables without the prefix for the backend.

Security note: In a real production app, you should also verify that the request to /verify-passport or /protected-image is coming from the legitimate user. In this simple demo, we trust the address from the client; however, an attacker could try to spoof a different address that they know has a high score. To mitigate this, you would implement an authentication step (for example, have the user sign a message proving ownership of the address – e.g., using SIWE (Sign-In with Ethereum) via Reown’s one-click auth). This demo focuses on Passport integration, so it skips the signed authentication for brevity. Always ensure that sensitive content is protected by both identity verification and score verification for true security.

Dynamic Light/Dark Theme Integration

The Passport Embeds package includes theming support. We imported DarkTheme and LightTheme from @human-tech/passport-embed and passed the appropriate one to the PassportScoreWidget. The selection is made by checking the user’s system theme via window.matchMedia('(prefers-color-scheme: dark)').

When the user’s device is in dark mode, the Passport widget will automatically use a dark color scheme (and vice versa for light mode). This provides a seamless UX where the embedded Passport UI matches the overall app theme. You can further customize the widget’s appearance by providing a custom theme object (the library allows adjusting colors if needed), but using the built-in themes ensures it stays consistent with Passport’s design.

Running the Demo Locally

  1. Configure environment: Ensure your .env file is in place with the API key, scorer ID, and Reown project ID. Also place an image file as backend/secret.png (this is the content to protect).
  2. Start the backend server: In one terminal, navigate to the backend directory and run:
node index.js

This will start the Express server on port 3001 (or the port you set in .env).

  1. Start the frontend dev server: Open another terminal, go to the frontend directory, and run:
npm run dev

This will start Vite’s development server (by default on http://localhost:5173 (opens in a new tab)).

  1. Open the app in your browser: Navigate to http://localhost:5173 (opens in a new tab). You should see the React app. If you are in dark mode, the Passport widget will appear in dark theme (light mode otherwise).

  2. Use the app: Click “Connect Your Wallet” (the Reown AppKit button). This will open a modal to select a wallet. Choose your Ethereum wallet (e.g., MetaMask) and connect. You may be prompted to sign a message to authenticate (Reown might use SIWE under the hood if configured, or a simple handshake).

  3. **After connecting, the app will display your wallet address and automatically load your Passport score:

  • If your Passport score meets the threshold (e.g., ≥20), the backend verification will run and unlock the protected image. You should see a success message and the secret image appear.
  • If your score is below the threshold, you’ll see a message that the content remains locked. You can then improve your Passport by adding more stamps via the Passport site (the widget may show a link or you can go to passport.gitcoin.co to add verifications). The widget in the app will update in real-time as you add stamps (you might need to refresh or re-connect the wallet after updating Passport). Once your score crosses the threshold, the app will detect isPassing and trigger the verification again, granting access.

That’s it!

You now have a fully functional local demo that uses a wallet login and Gitcoin Passport to protect content. The frontend checks the Passport score client-side for instant feedback, and the backend performs an authoritative check before serving the sensitive content. The Passport widget provides a user-friendly way for users to see and improve their score, and the theme integration ensures it looks good in both light and dark modes.

Important Takeaways

  • Passport Integration: Using usePassportScore (client-side) allows you to easily retrieve a user’s Passport score and passing status in React. The Passport Embeds package also provides pre-built UI (PassportScoreWidget) to display the user’s verification status and guide them to add more credentials if needed.
  • Reown AppKit (WalletConnect): This demo uses Reown to simplify wallet connections. In a real app, you can also leverage Reown for one-click social logins or session management. The <appkit-button> and ReOwnProvider make it trivial to add a wallet login that works with many providers.
  • Backend Verification: Never trust the front-end alone for gating critical content or actions. We use an Express backend to call the Passport API directly (using our secret API key) and verify the score server-side【9†】. Only after this check do we send down the protected asset. This ensures that users (or attackers) cannot simply bypass the gate by manipulating browser code or responses.
  • Dynamic Theming: By respecting the user’s prefers-color-scheme and using Passport’s theming options, we provide a better user experience. Passport’s DarkTheme and LightTheme keep the embed consistent with the app’s look and feel without additional work.
  • Extensibility: While this demo protects an image, you can similarly protect routes, components, or on-chain actions (e.g., only allow minting an NFT if backendStatus === 'passed'). Just ensure the final action always goes through a server or contract check. Gitcoin Passport can also be combined with other identity signals or zero-knowledge proofs for enhanced security.

Next steps