π― 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.sol
implements 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
-
organizer
is the signer. -
claim()
verifies the signature, expiry, and nonce. -
used[digest]
prevents replay attacks. -
EntryGranted
confirms 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)