MetaMask

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).

  1. Decide the public domain and origin users will sign for (e.g. localhost / http://localhost:3000 in local dev)
  2. Set METAMASK_DOMAIN and METAMASK_URI to match that host and origin exactly
  3. Set METAMASK_STATEMENT (shown in the wallet prompt)
  4. Set METAMASK_CHAIN_IDS for every chain you accept (e.g. 1 or 1,11155111)
  5. Optionally tune METAMASK_NONCE_TTL_SECONDS (default 300)
  6. 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.

Environment.env
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

  1. Client POSTs /api/v1/auth/metamask/nonce and receives { nonce, expiresIn }
  2. Client connects the wallet (eth_requestAccounts) and builds an EIP-4361 message using domain, URI, statement, chainId, and nonce
  3. Wallet signs with personal_sign; client POSTs { message, signature } to /api/v1/auth/metamask/verify
  4. 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.

HTTPPOST /api/v1/auth/metamask/nonce
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.

HTTP RequestPOST /api/v1/auth/metamask/verify
POST /api/v1/auth/metamask/verify
Content-Type: application/json

{
  "message": "",
  "signature": "0x..."
}
HTTP Response200 OK
{
  "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.

JavaScriptNonce + personal_sign + 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.

React NativeVerify after wallet signature
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