DEV Community

Cover image for πŸ›‘οΈ Day 19 of #30DaysOfSolidity β€” Signature-Based Web3 Authentication for Private Events
Saurav Kumar
Saurav Kumar

Posted on

πŸ›‘οΈ Day 19 of #30DaysOfSolidity β€” Signature-Based Web3 Authentication for Private Events

🎯 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
Enter fullscreen mode Exit fullscreen mode

πŸ”Ή 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:

  1. Organizer signs an invite off-chain including:
  • Attendee address
  • Event ID
  • Expiry timestamp
  • Unique nonce
  1. Attendee receives the signed message (via email, QR, or backend API).

  2. Attendee submits the signature on-chain by calling claim().

  3. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸ”‘ 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();
Enter fullscreen mode Exit fullscreen mode

πŸ’» 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

πŸ”’ 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

  1. Deploy contract:
forge create src/SignatureGate.sol:SignatureGate --constructor-args <organizer_address>
Enter fullscreen mode Exit fullscreen mode
  1. Sign invites with the Node.js script.
  2. Guests submit signatures via the React frontend.
  3. 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)