π― Introduction
Welcome to Day 19 of #30DaysOfSolidity!
In this tutorial, weβll build a signature-based Web3 authentication system β a gas-efficient, secure, and scalable solution for private Ethereum events, token-gated workshops, and VIP meetups.
Instead of maintaining an on-chain whitelist, the organizer signs invitations off-chain, and attendees verify their entry on-chain using smart contract verification. This approach mirrors real-world event workflows: off-chain approval, on-chain authentication.
By the end of this guide, youβll understand how to implement Web3 authentication with Solidity and Foundry, minimizing gas costs while maintaining full security.
βοΈ Tech Stack
| Layer | Technology |
|---|---|
| Smart Contract | Solidity (^0.8.20) |
| Testing & Deployment | Foundry |
| Off-chain Signer | Node.js + Ethers.js |
| Frontend Demo | React + Ethers.js |
| Network | Ethereum / EVM-compatible |
Absolutely! We can add a file structure section to the blog to make it more developer-friendly and professional. Hereβs the updated section for your Dev.to blog with proper structure placement:
π Project File Structure
Hereβs the recommended file structure for the Signature-Based Web3 Authentication project:
signature-gate/ β ββ contracts/ β ββ SignatureGate.sol # Smart contract for signature-based entry β ββ scripts/ β ββ signer.js # Node.js script for organizer to sign invites β ββ frontend/ β ββ src/ β β ββ components/ β β β ββ ClaimEntry.jsx # React component to claim entry β β ββ SignatureGateABI.json # ABI generated from contract β β ββ App.jsx # Main React app β ββ package.json # Frontend dependencies β ββ test/ β ββ SignatureGate.t.sol # Foundry test file for contract β ββ foundry.toml # Foundry configuration ββ README.md # Project README πΉ Explanation
- contracts/ β Contains Solidity smart contracts;
SignatureGate.solimplements the verification logic. - scripts/ β Off-chain scripts for signing messages (
signer.js). - frontend/ β React app demonstrating on-chain claim functionality with Ethers.js.
- test/ β Foundry tests for signature validation, replay protection, and expiry checks.
- foundry.toml β Configuration for Foundry deployments and testing.
- README.md β Project documentation.
π‘ Core Concept: Signature-Based Web3 Authentication
Traditional token-gated events require storing on-chain whitelists. This is costly and cumbersome.
With signature-based entry, we only store the used signatures on-chain. Hereβs how it works:
- Organizer signs an invite off-chain including:
- Attendee address
- Event ID
- Expiry timestamp
- Unique nonce
Attendee receives the signed message (via email, QR, or backend API).
Attendee submits the signature on-chain by calling
claim().Contract verifies the signature using
ecrecover:
- Confirms the signer is the organizer
- Checks for expiration
- Ensures the signature hasnβt been reused
β
If valid, the attendee is granted entry, and the contract emits an EntryGranted event.
π§© Smart Contract β SignatureGate.sol
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract SignatureGate { address public organizer; mapping(bytes32 => bool) public used; event EntryGranted(address indexed attendee, uint256 indexed eventId, uint256 nonce); event OrganizerChanged(address oldOrganizer, address newOrganizer); error InvalidSignature(); error SignatureExpired(); error SignatureAlreadyUsed(); error NotOrganizer(); constructor(address _organizer) { require(_organizer != address(0), "organizer zero"); organizer = _organizer; } function setOrganizer(address _new) external { if (msg.sender != organizer) revert NotOrganizer(); emit OrganizerChanged(organizer, _new); organizer = _new; } function claim( uint256 eventId, uint256 expiry, uint256 nonce, bytes calldata sig ) external { if (expiry != 0 && block.timestamp > expiry) revert SignatureExpired(); bytes32 digest = _hashForSigning(msg.sender, eventId, expiry, nonce); if (used[digest]) revert SignatureAlreadyUsed(); address signer = _recover(digest, sig); if (signer != organizer) revert InvalidSignature(); used[digest] = true; emit EntryGranted(msg.sender, eventId, nonce); } function hashForSigning( address attendee, uint256 eventId, uint256 expiry, uint256 nonce ) external pure returns (bytes32) { return _hashForSigning(attendee, eventId, expiry, nonce); } function _hashForSigning( address attendee, uint256 eventId, uint256 expiry, uint256 nonce ) internal pure returns (bytes32) { bytes32 raw = keccak256(abi.encodePacked("\x19Event Entry:\n", attendee, eventId, expiry, nonce)); return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", raw)); } function _recover(bytes32 digest, bytes memory sig) internal pure returns (address) { if (sig.length != 65) return address(0); bytes32 r; bytes32 s; uint8 v; assembly { r := mload(add(sig, 0x20)) s := mload(add(sig, 0x40)) v := byte(0, mload(add(sig, 0x60))) } if (v < 27) v += 27; return ecrecover(digest, v, r, s); } } π How It Works
-
organizeris the signer. -
claim()verifies the signature, expiry, and nonce. -
used[digest]prevents replay attacks. -
EntryGrantedconfirms successful verification.
π§ͺ Off-Chain Signing β Node.js Script
The organizer generates signatures for attendees:
import { ethers } from "ethers"; // node signer.js <PK> <ATTENDEE> <EVENT_ID> <EXPIRY> <NONCE> async function main() { const [pk, attendee, eventId, expiry, nonce] = process.argv.slice(2); const wallet = new ethers.Wallet(pk); const abiPacked = ethers.utils.solidityPack( ["string", "address", "uint256", "uint256", "uint256"], ["\x19Event Entry:\n", attendee, eventId, expiry, nonce] ); const raw = ethers.utils.keccak256(abiPacked); const signature = await wallet.signMessage(ethers.utils.arrayify(raw)); console.log("Signature:", signature); } main(); π» Frontend Demo β React + Ethers.js
import React, { useState } from "react"; import { ethers } from "ethers"; import SignatureGateABI from "./SignatureGateABI.json"; export default function ClaimEntry({ contractAddress }) { const [eventId, setEventId] = useState(""); const [expiry, setExpiry] = useState(""); const [nonce, setNonce] = useState(""); const [sig, setSig] = useState(""); const [status, setStatus] = useState(""); async function claimEntry() { const provider = new ethers.providers.Web3Provider(window.ethereum); const signer = provider.getSigner(); const contract = new ethers.Contract(contractAddress, SignatureGateABI, signer); try { const tx = await contract.claim(eventId, expiry, nonce, sig); setStatus("Transaction sent: " + tx.hash); await tx.wait(); setStatus("β
Entry confirmed!"); } catch (err) { setStatus("β Error: " + err.message); } } return ( <div> <h3>Claim Event Entry</h3> <input placeholder="Event ID" onChange={(e) => setEventId(e.target.value)} /> <input placeholder="Expiry (Unix)" onChange={(e) => setExpiry(e.target.value)} /> <input placeholder="Nonce" onChange={(e) => setNonce(e.target.value)} /> <input placeholder="Signature" onChange={(e) => setSig(e.target.value)} /> <button onClick={claimEntry}>Submit</button> <p>{status}</p> </div> ); } π Security Considerations
| Concern | Mitigation |
|---|---|
| Replay attacks | Nonce + signature hash stored |
| Expired invites | Expiry timestamp |
| Key compromise | setOrganizer() allows rotation |
| Gas costs | Only store successful claim hashes |
| Privacy | Off-chain approvals, no public whitelist |
Pro tip: For production, use EIP-712 typed signatures for wallet-friendly structured signing.
π Deployment Guide
- Deploy contract:
forge create src/SignatureGate.sol:SignatureGate --constructor-args <organizer_address> - Sign invites with the Node.js script.
- Guests submit signatures via the React frontend.
- The contract verifies and emits entry confirmation events.
π Use Cases
- Token-gated events & workshops
- DAO community meetups
- NFT VIP access
- KYC-free gated dApps
- Decentralized conference check-ins
π Summary
This signature-based Web3 authentication system is gas-efficient, secure, and real-world ready.
It provides off-chain invitation flexibility with on-chain verification, making it perfect for private Ethereum events.
βEfficient, secure, and decentralized β the future of Web3 event access control.β
Top comments (0)