Sign-in With Ethereum (SIWE) is a method for using Ethereum addresses to authenticate on off-chain services. Implementing SIWE means that your users don’t need to create a separate account on your website as long as they already have an address on Ethereum or any other EVM-compatible chain.
SIWE allows authentication on decentralized apps (dApps), where users must already have an address to transact. Authenticating with that address saves them from needing to manage separate login info.
SIWE relies on the same public-key cryptography that addresses use to transact on EVM chains. In brief:
your-dapp.xyz wants you to sign in with your Ethereum Address:
0x105d00D5f671B236AfED1D2EEF5D7d566382E8C3
URI: your-dapp.xyz/login
Message Version: 2.1
Chain ID: 1
Nonce: 92649128
Issued At: 2025-10-04T17:00:00Z
Django is a commonly used Python web framework. It provides abstractions that allow you to easily create database models, set up CRUD and more complicated endpoints, and handle user permissions.
OK, so Django is a conventional web framework. But SIWE is mostly for dApps, right? Why use it with Django? Well, dApps store data on blockchains, but you may want to store some user or app data off-chain to:
1) Save gas on storage space 2) Store data types like videos that have poor support on blockchains 3) Reduce latency
If you want to create a hybrid dApp that also stores data on a centralized server, Django is a good choice because of its rapid development, ease of maintenance, and good scalability. Here are some specific hybrid dApp use cases:
So, to create a hybrid Django dApp, you will likely want to implement SIWE in Django.
Your SIWE implementation will need three key components:
1) A SiweUser model that extends the base Django user model with an Ethereum address field 2) SIWE verification logic and an authentication backend 3) A SIWE login view
Let’s put these components into their own django app within your project.
python manage.py startapp siweauth
I did this for a project earlier this year, so you can also check out my implementation here. Note that I used JWTs instead of Django’s built-in session framework, so there will be some differences in how you manage user sessions.
Django’s base user model has almost everything you need for basic auth and user management. We just need to add a field for the user’s Ethereum address and a manager method to create users with just an address.
We will also need a model for the SIWE message’s nonce. This nonce should be single-use and have an expiration time.
# siweauth/models.py
"""
Database models for SIWE
"""
from django.db import models
from django.core.validators import RegexValidator
from django.core.exceptions import ValidationError
from django.contrib.auth.models import BaseUserManager, AbstractBaseUser
from web3 import Web3
def validate_ethereum_address(value):
"""
Validate that an Ethereum address is correctly checksummed.
Args:
value: The Ethereum address to validate
Raises:
ValidationError: If the address is not a valid checksummed Ethereum address
"""
if not Web3.isChecksumAddress(value):
raise ValidationError
class Nonce(models.Model):
"""
A temporary nonce used for SIWE authentication.
Nonces are single-use random values that prevent replay attacks during
authentication and have expiration times.
"""
value = models.CharField(max_length=24, primary_key=True)
expiration = models.DateTimeField()
def __str__(self):
return self.value
class SiweUserManager(BaseUserManager):
"""
Manager for the custom SiweUser model.
This manager provides methods for creating users authenticated via SIWE
"""
def create_user_address(self, address):
"""
Create and save a SiweUser with the given Ethereum address.
Args:
address: The Ethereum wallet address
Returns:
SiweUser: The created user instance
Raises:
ValueError: If no address is provided
"""
if not address:
raise ValueError("SiweUsers must have an eth address")
user = self.model(
wallet=address,
)
user.save(using=self._db)
return user
class SiweUser(AbstractBaseUser):
"""
This model extends AbstractBaseUser to support authentication via SIWE.
"""
wallet = models.CharField(
verbose_name="Wallet Address",
max_length=42,
unique=True,
primary_key=True,
validators=[
RegexValidator(regex=r"^0x[a-fA-F0-9]{40}$"),
validate_ethereum_address,
],
)
username = models.CharField(max_length=150, blank=True, null=True, unique=True)
email = models.CharField(max_length=150, blank=True, null=True, unique=True)
is_admin = models.BooleanField(default=False)
is_staff = models.BooleanField(default=False)
password = models.CharField(max_length=128, blank=True)
objects = SiweUserManager()
USERNAME_FIELD = "wallet"
REQUIRED_FIELDS = []
def __str__(self):
return self.wallet
@property
def is_superuser(self):
return self.is_admin
def has_perm(self, perm, obj=None):
return self.is_admin
def has_module_perms(self, app_label):
return self.is_admin
Now, register the custom user model in your settings.py file.
# your_project/settings.py
...
AUTH_USER_MODEL = "siweauth.SiweUser"
...
We will write custom authentication methods to verify SIWE methods and allow authentication by Ethereum address.
First, let’s create some settings for those SIWE message fields. Our validation will check that the messages’ fields match these expected values. These settings are hardcoded so you can see them, but you should set yours in environment variables for security.
# your_project/settings.py
...
# SIWE message validity window in minutes
SIWE_MESSAGE_VALIDITY = 5 # 5 minutes
# Expected chain ID for SIWE messages
SIWE_CHAIN_ID = 11155111 # Ethereum sepolia testnet
# Expected domain for SIWE messages
SIWE_DOMAIN = "your-dapp.xyz"
# Expected URI for SIWE messages
SIWE_URI = "https://your-dapp.xyz/login"
...
Now, we need to write some functions to verify SIWE messages using those settings.
_nonce_is_valid
ensures that our SIWE messages’ nonces are valid.parse_siwe_message
splits our plaintext SIWE messages into fields for validation.check_siwe
validates those fields.# siweauth/auth.py
import datetime, pytz
from web3 import Web3
from hexbytes import HexBytes
from eth_account.messages import encode_defunct
import re
from your_project.settings import (
SIWE_MESSAGE_VALIDITY,
SIWE_CHAIN_ID,
SIWE_DOMAIN,
SIWE_URI,
)
from siweauth.models import Nonce
w3 = Web3()
def _nonce_is_valid(nonce: str) -> bool:
"""
Check if given nonce exists and has not yet expired.
:param nonce: The nonce string to validate.
:return: True if valid else False.
"""
n = Nonce.objects.filter(value=nonce).first()
is_valid = False
if n is not None:
if n.expiration > datetime.now(tz=pytz.UTC):
is_valid = True
n.delete()
return is_valid
def parse_siwe_message(message_body: str) -> dict:
"""
Parse SIWE message into components.
Returns a dict or None if parsing fails.
"""
try:
domain_match = re.match(r"^([^\s]+) wants you to sign in with your Ethereum Address:", message_body)
address_match = re.search(r"Ethereum Address:\n(0x[a-fA-F0-9]{40})", message_body)
uri_match = re.search(r"^URI:\s*(.+)$", message_body, re.MULTILINE)
version_match = re.search(r"^Message Version:\s*(.+)$", message_body, re.MULTILINE)
chain_id_match = re.search(r"^Chain ID:\s*(\d+)$", message_body, re.MULTILINE)
nonce_match = re.search(r"^Nonce:\s*(.+)$", message_body, re.MULTILINE)
issued_at_match = re.search(r"^Issued At:\s*(.+)$", message_body, re.MULTILINE)
if not (domain_match and address_match and uri_match and version_match and chain_id_match and nonce_match and issued_at_match):
return None
domain = domain_match.group(1).strip()
address = address_match.group(1).strip()
uri = uri_match.group(1).strip()
version = version_match.group(1).strip()
chain_id = int(chain_id_match.group(1))
nonce = nonce_match.group(1).strip()
issued_at = datetime.fromisoformat(issued_at_match.group(1).strip())
return {
"domain": domain,
"address": address,
"uri": uri,
"version": version,
"chain_id": chain_id,
"nonce": nonce,
"issued_at": issued_at,
}
except Exception as e:
# Optionally log the error here
return None
def check_siwe(message, signed_message):
# check for format
if type(message) != str:
body = str(message.decode())
else:
body = message
# Parse message components
parsed = parse_siwe_message(body)
if not parsed:
return None
# Validate timestamp
now = datetime.now(parsed["issued_at"].tzinfo)
if abs((now - parsed["issued_at"]).total_seconds()) > SIWE_MESSAGE_VALIDITY * 60:
return None
# Validate chain ID
if parsed["chain_id"] != SIWE_CHAIN_ID:
return None
# Validate domain and URI
if parsed["domain"] != SIWE_DOMAIN or parsed["uri"] != SIWE_URI:
return None
# check for nonce in db
if not _nonce_is_valid(parsed["nonce"]):
return None
# recover address from nonce / signed message
address = parsed["address"]
try:
recovered_address = w3.eth.account.recover_message(
signable_message=encode_defunct(text=body),
signature=HexBytes(signed_message),
)
except:
return None
# make sure recovered address is correct
if address != recovered_address:
return None
return recovered_address
A Django authentication backend is a class that implements two methods, authenticate
and get_user
. Let’s write a custom authentication backend that uses our verification methods to implement those methods with SIWE.
# siweauth/backend.py
from django.contrib.auth.backends import BaseBackend
from web3 import Web3
from eth_account.messages import SignableMessage
import logging
from siweauth.models import SiweUser
from siweauth.auth import check_siwe
w3 = Web3()
class SiweBackend(BaseBackend):
"""
Authentication backend for Sign-In with Ethereum.
"""
def authenticate(
self, request, message: SignableMessage = None, signed_message=None
):
"""
Authenticate a user with SIWE
Args:
request: The HTTP request
message: The SIWE message
signed_message: The signature of the message
Returns:
SiweUser: The authenticated user, or None if authentication fails
Note:
Creates a new user if one doesn't exist for the recovered address
"""
# request must have message and signed_message fields
if None in [message, signed_message]:
return None
recovered_address = check_siwe(message, signed_message)
if recovered_address is None:
return None
# if user exists, return user
user = SiweUser.objects.filter(wallet=recovered_address).first()
# if user doesn't exist, make a user for this wallet
if user is None:
user = SiweUser.objects.create_user_address(recovered_address)
return user
def get_user(self, user_address):
"""
Retrieve a user by user address.
Args:
user_address: The address of the user to retrieve
Returns:
SiweUser: The user with the given address, or None if not found
"""
try:
return SiweUser.objects.get(pk=user_address)
except SiweUser.DoesNotExist:
return None
Finally, we need a view that will handle SIWE login requests. This view will call our custom SIWE authentication backend and log the user in if they pass our SIWE verification. Here is a super simple view for this purpose:
# siweauth/views.py
from django.contrib.auth import login
from your_siwe_package import authenticate
def siwe_login_view(request):
message = request.POST.get("message")
signed_message = request.POST.get("signature")
# This calls your SIWE authentication backend
user = authenticate(message=message, signed_message=signed_message)
if user is not None:
login(request, user) # This creates a session and sets the sessionid cookie
return JsonResponse({"success": True})
else:
return JsonResponse({"success": False}, status=401)
Here’s an example request to this view:
POST /login HTTP/1.1
Host: your-dapp.xyz
Content-Type: application/x-www-form-urlencoded
message=your_siwe_message&signature=0xYourSignature
where your_siwe_message
is the plaintext SIWE message, and 0xYourSignature
is the signature of that message.
Testing your SIWE implementation is essential for maintaining security. To get some ideas for tests, check out my implementation’s siweauth tests. In general, you will need to
Rig up your frontend to use these login views and you will be off to the races with your hybrid dApp. Your users will be able to log in with Ethereum and use your app without breaking their decentralized flow, but you will still be able to track user info with a performant and scalable centralized database.