MetaMask SIWE
Sign-In with Ethereum (EIP-4361) for web wallets — nonce issuance, personal_sign verification, JWT tokens, and frontend examples. No OAuth redirect.
Overview
MetaMask login is optional and controlled by METAMASK_AUTH_ENABLED. When disabled, MetaMask endpoints return 503 Service Unavailable. This is not OAuth: the API implements Sign-In with Ethereum (SIWE / EIP-4361). The client requests a one-time nonce, the wallet signs a structured message with personal_sign, and POST /auth/metamask/verify returns the same JWT shape as password login. There is no browser redirect callback and no POST /auth/exchange step.
- • Feature-flagged via environment
- • MetamaskSiweService verifies EIP-4361 messages (domain, URI, chainId, nonce, signature)
- • Nonce stored in Redis with TTL from METAMASK_NONCE_TTL_SECONDS (default 300)
- • No Developer Portal app — only domain / URI / statement / chain IDs must match the client message
- • Synthetic email {address}@users.noreply.metamask.local — MetaMask has no email
- • New users get a random display name (adjective + noun)
- • Same token response shape as POST /auth/login
MetaMask / SIWE setup
No external OAuth console is required. Align METAMASK_DOMAIN and METAMASK_URI with the host and origin embedded in the client SIWE message. Set METAMASK_STATEMENT to the human-readable statement users sign, and METAMASK_CHAIN_IDS to the allowed chain IDs (comma-separated, default 1).
- Decide the public domain and origin users will sign for (e.g. localhost / http://localhost:3000 in local dev)
- Set METAMASK_DOMAIN and METAMASK_URI to match that host and origin exactly
- Set METAMASK_STATEMENT (shown in the wallet prompt)
- Set METAMASK_CHAIN_IDS for every chain you accept (e.g. 1 or 1,11155111)
- Optionally tune METAMASK_NONCE_TTL_SECONDS (default 300)
- Enable METAMASK_AUTH_ENABLED=true in .env
Environment variables
Loaded from .env via src/config/metamask-auth.config.ts (namespace metamaskAuth on ConfigService). See .env.example.
METAMASK_AUTH_ENABLED=true METAMASK_DOMAIN=localhost METAMASK_URI=http://localhost:3000 METAMASK_STATEMENT=Sign in to the NestJS Boilerplate METAMASK_CHAIN_IDS=1 METAMASK_NONCE_TTL_SECONDS=300 METAMASK_OAUTH_DEFAULT_ROLES=
Default roles for new MetaMask users
New MetaMask users receive roles: [] by default (same as password register). Set METAMASK_OAUTH_DEFAULT_ROLES in .env to a comma-separated list of Role values (e.g. user or user,manager). Existing users linked by metamaskId keep their current roles.
Sequence
- Client POSTs /api/v1/auth/metamask/nonce and receives { nonce, expiresIn }
- Client connects the wallet (eth_requestAccounts) and builds an EIP-4361 message using domain, URI, statement, chainId, and nonce
- Wallet signs with personal_sign; client POSTs { message, signature } to /api/v1/auth/metamask/verify
- API verifies SIWE, upserts user by metamaskId, returns user + accessToken + refreshToken
API reference
1. Issue nonce
Public endpoint. Creates a single-use nonce in Redis (TTL METAMASK_NONCE_TTL_SECONDS, default 300). Returns 503 when MetaMask auth is disabled.
POST /api/v1/auth/metamask/nonce
→ { "nonce": "...", "expiresIn": 300 }503 feature disabled
2. Verify SIWE signature
Public endpoint. Validates the EIP-4361 message against configured domain / URI / statement / chain IDs, checks the nonce, recovers the address from the signature, upserts the user, and returns JWTs (same shape as login). No exchange code.
POST /api/v1/auth/metamask/verify
Content-Type: application/json
{
"message": "",
"signature": "0x..."
} {
"user": {
"id": "...",
"email": "[email protected]",
"firstName": "Fearful",
"lastName": "Bear",
"roles": [],
"metamaskId": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266",
"isActive": true
},
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}400 invalid SIWE / bad signature / expired nonce · 503 feature disabled
Frontend — Web (wallet)
Typical flow with MetaMask in the browser: request accounts, fetch a nonce, build the SIWE message so domain, URI, statement, and chainId match server config, then personal_sign and call verify.
const API = 'http://localhost:3000/api/v1';
const [{ nonce }] = await Promise.all([
fetch(`${API}/auth/metamask/nonce`, { method: 'POST' }).then((r) => r.json()),
window.ethereum.request({ method: 'eth_requestAccounts' }),
]);
const [address] = await window.ethereum.request({ method: 'eth_accounts' });
const message =
`${window.location.host} wants you to sign in with your Ethereum account:\n` +
`${address}\n\nSign in to the NestJS Boilerplate\n\n` +
`URI: ${window.location.origin}\nVersion: 1\nChain ID: 1\nNonce: ${nonce}\n` +
`Issued At: ${new Date().toISOString()}`;
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [message, address],
});
const res = await fetch(`${API}/auth/metamask/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, signature }),
});
if (!res.ok) throw new Error(await res.text());
const { user, accessToken, refreshToken } = await res.json();
// persist tokens as your app requires
sessionStorage.setItem('accessToken', accessToken);
// MetaMask has no real email — use metamaskId as the stable identifier
console.log('signed in', user.metamaskId);- • METAMASK_DOMAIN must match the host used in the SIWE message (often window.location.host)
- • METAMASK_URI must match the origin / URI field in the message
- • Chain ID in the message must be listed in METAMASK_CHAIN_IDS
- • Store accessToken / refreshToken securely after verify
Frontend — Mobile (WalletConnect / deep link)
On mobile, use a wallet SDK (WalletConnect, MetaMask SDK, etc.) that can personal_sign an EIP-4361 message. Call the same nonce and verify endpoints over HTTPS — there is no OAuth redirect allowlist.
const API = 'https://api.example.com/api/v1';
async function loginWithMetamask(message, signature) {
const res = await fetch(`${API}/auth/metamask/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, signature }),
});
const data = await res.json();
// data.accessToken, data.refreshToken, data.user
return data;
}- • Point the app at your API base URL over HTTPS
- • Keep METAMASK_DOMAIN / METAMASK_URI aligned with the values you put in the SIWE message
- • Identify users by metamaskId — not by synthetic email
Security notes
- • SIWE binds the signature to domain, URI, chainId, and a single-use Redis nonce
- • Reject mismatched domain / URI / statement / chainId — do not loosen checks in production
- • MetaMask never provides email — synthetic address is for internal upsert only
- • MetaMask-only users have password null — password login / change-password will fail until a password is set
- • Prefer HTTPS origins in production so URI and domain cannot be spoofed by local malware easily
User model
Postgres entity / interface fields used by MetaMask login:
- • metamaskId — unique nullable lowercased Ethereum address
- • email — synthetic {address}@users.noreply.metamask.local
- • password — nullable for SIWE-only accounts
- • firstName / lastName — random adjective + noun on first login
- • isActive — set true for new MetaMask users
- • roles — from METAMASK_OAUTH_DEFAULT_ROLES env (default [])
Related
- Social Authentication hub
- Google OAuth2 guide
- Facebook OAuth2 guide
- X / Twitter OAuth2 guide
- GitHub OAuth2 guide
- Figma OAuth2 guide
- LinkedIn OpenID Connect guide
- Slack OpenID Connect guide
- Atlassian OAuth 2.0 (3LO) guide
- GitLab OAuth2 guide
- Bitbucket OAuth2 guide
- Discord OAuth2 guide
- Twitch OAuth2 guide
- Steam OpenID 2.0 guide
- Amazon OAuth2 guide
- Patreon OAuth2 guide
- Dropbox OAuth2 guide
- Apple Sign In guide
- Reddit OAuth2 guide
- Auth & JWT