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
andviem
(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 providedprojectId
and network. The adapter’swagmiConfig
is passed into Wagmi. ReOwnProvider
wraps our app to handle the WalletConnect modal and any Reown-specific context. By includingReOwnProvider
, 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, theuseAccount
hook will populate theaddress
. - 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
anderror
: 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 sameapiKey
,scorerId
, andaddress
. We pass atheme
prop to it, choosingDarkTheme
orLightTheme
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
), auseEffect
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.
- If
- 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 overallscore
and a booleanpassing_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 usessendFile
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
andVITE_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
- Configure environment: Ensure your
.env
file is in place with the API key, scorer ID, and Reown project ID. Also place an image file asbackend/secret.png
(this is the content to protect). - 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).
- 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)).
-
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).
-
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).
-
**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’sDarkTheme
andLightTheme
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
- Learn how to customize your Passport Embed
- Learn how to use the Passport API
- Review the Embed component reference