Skip to content

fix(siwe): normalize Ethereum address to lowercase to prevent duplicate identities#2507

Open
mastermanas805 wants to merge 1 commit intosupabase:masterfrom
mastermanas805:fix/siwe-normalize-address-case
Open

fix(siwe): normalize Ethereum address to lowercase to prevent duplicate identities#2507
mastermanas805 wants to merge 1 commit intosupabase:masterfrom
mastermanas805:fix/siwe-normalize-address-case

Conversation

@mastermanas805
Copy link
Copy Markdown

@mastermanas805 mastermanas805 commented Apr 26, 2026

What

Fixes #2264.

Ethereum addresses are protocol-level case-insensitive: 0xABC... and 0xabc... are the same wallet. The EIP-55 mixed-case form is a visual checksum, not a distinct identifier. The SIWE parser at internal/utilities/siwe/parser.go stores the address verbatim from the signed message, and web3GrantEthereum in internal/api/web3.go uses that exact string as part of the identity provider_id. As a result, the same wallet signing in with two different case representations creates two distinct rows in auth.users and auth.identities.

How to reproduce on master (for reviewers)

This requires signing two SIWE messages with the same Ethereum private key, one with a mixed-case address line, one with a lowercase address line, and posting each to /token?grant_type=web3. A self-contained Go reproducer is below.

Step 1: Bring up auth with Web3 enabled

cd auth
cp example.docker.env .env.docker
# Edit .env.docker:
#   - set GOTRUE_JWT_SECRET to a 32+ char string
#   - add: GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED=true
make dev
# In another shell:
make migrate_dev

Step 2: Save this reproducer to a fresh directory

siwe-repro/main.go:

package main

import (
	"bytes"
	"crypto/ecdsa"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/ethereum/go-ethereum/accounts"
	"github.com/ethereum/go-ethereum/crypto"
)

const (
	authURL  = "http://localhost:9999/token?grant_type=web3"
	domain   = "localhost"
	uri      = "http://localhost/login"
	version  = "1"
	chainID  = "1"
	fixedKey = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"
)

func main() {
	priv, _ := crypto.HexToECDSA(fixedKey)
	addrChecksum := crypto.PubkeyToAddress(*priv.Public().(*ecdsa.PublicKey)).Hex()
	addrLower := strings.ToLower(addrChecksum)
	fmt.Printf("Wallet (EIP-55):    %s\nWallet (lowercase): %s\n\n", addrChecksum, addrLower)

	t1 := time.Now().UTC().Format(time.RFC3339)
	t2 := time.Now().UTC().Add(time.Second).Format(time.RFC3339)
	id1 := signAndPost(priv, build(addrChecksum, "abcdef0123456789abcdef0123456789", t1), "MIXED-CASE")
	id2 := signAndPost(priv, build(addrLower, "fedcba9876543210fedcba9876543210", t2), "LOWERCASE")

	fmt.Printf("\n=== RESULT ===\nmixed-case user_id: %s\nlowercase user_id:  %s\n", id1, id2)
	if id1 == id2 && id1 != "" {
		fmt.Println("\nSAME user_id — fix is in place.")
	} else {
		fmt.Println("\nDIFFERENT user_ids — bug #2264 confirmed.")
	}
}

func build(address, nonce, issuedAt string) string {
	return fmt.Sprintf("%s wants you to sign in with your Ethereum account:\n%s\n\nURI: %s\nVersion: %s\nChain ID: %s\nNonce: %s\nIssued At: %s",
		domain, address, uri, version, chainID, nonce, issuedAt)
}

func signAndPost(priv *ecdsa.PrivateKey, msg, label string) string {
	hash := accounts.TextHash([]byte(msg))
	sig, _ := crypto.Sign(hash, priv)
	if sig[64] < 27 {
		sig[64] += 27
	}
	body, _ := json.Marshal(map[string]any{"chain": "ethereum", "message": msg, "signature": "0x" + hex.EncodeToString(sig)})
	req, _ := http.NewRequest("POST", authURL, bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		return ""
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(resp.Body)
	var parsed map[string]any
	json.Unmarshal(raw, &parsed)
	fmt.Printf("=== POST (%s) === HTTP %d\n", label, resp.StatusCode)
	if u, ok := parsed["user"].(map[string]any); ok {
		id, _ := u["id"].(string)
		fmt.Printf("user.id: %s\n", id)
		return id
	}
	fmt.Printf("response: %s\n", string(raw))
	return ""
}

siwe-repro/go.mod:

module siwerepro

go 1.22

require github.com/ethereum/go-ethereum v1.14.0

Step 3: Run

cd siwe-repro
go mod tidy
go run main.go

You will see two HTTP 200 responses with two different user.id values, ending with: DIFFERENT user_ids — bug #2264 confirmed.

Step 4: Inspect the database

docker exec auth-postgres-1 psql -U supabase_auth_admin -d postgres -c \
  "SELECT user_id, provider_id FROM auth.identities WHERE provider='web3' ORDER BY created_at DESC LIMIT 5;"

Two rows for the same wallet, distinguished only by case in provider_id.

Root cause

internal/utilities/siwe/parser.go validates the address with ^0x[a-fA-F0-9]{40}$ and stores it verbatim on the parsed SIWEMessage. In internal/api/web3.go, web3GrantEthereum constructs providerId := "web3:" + chain + ":" + parsedMessage.Address, and models.FindIdentityByIdAndProvider does case-sensitive equality on provider_id. So 0xABC... and 0xabc... look like distinct identities to the lookup and each path through createAccountFromExternalIdentity creates a fresh user.

Fix

One-line normalization at the parser entry: address = strings.ToLower(address) immediately after the regex match succeeds. Single point of truth, no caller changes required. This mirrors the existing email lowercase-normalization pattern in models.Identity.

Solana SIWS is intentionally untouched: base58 is case-sensitive and lowercasing it would corrupt valid wallet identifiers.

How to verify the fix manually (for reviewers)

After applying this PR's diff:

Step 1: Hot-reload the auth binary

docker compose -f docker-compose-dev.yml restart auth

(Or just save the file — CompileDaemon rebuilds within ~10 seconds.)

Step 2: Clean the duplicate rows from your earlier reproduction run

docker exec auth-postgres-1 psql -U supabase_auth_admin -d postgres -c \
  "DELETE FROM auth.identities WHERE provider='web3'; \
   DELETE FROM auth.users WHERE raw_user_meta_data->>'sub' LIKE 'web3:%';"

Step 3: Re-run the reproducer

cd siwe-repro && go run main.go

Output now ends with: SAME user_id — fix is in place.

Step 4: Re-inspect the database

docker exec auth-postgres-1 psql -U supabase_auth_admin -d postgres -c \
  "SELECT user_id, provider_id FROM auth.identities WHERE provider='web3';"

Single row for the wallet, provider_id in the lowercase canonical form.

Automated tests

internal/utilities/siwe/parser_test.go (added in this PR):

  • TestParseMessageAddressNormalization — table-driven test with case-variant inputs; asserts they all produce the same lowercase Address. Fails on master, passes after this fix.
  • Two pre-existing TestParseMessage/positive_example_* cases updated so their expected values use the lowercase form (the parser used to return verbatim case; this is a behavior change in the parser's contract).

Run:

go test ./internal/utilities/siwe/ -count=1 -v

Verification I ran locally

  • go test ./internal/utilities/siwe/ -count=1 — green
  • go test ./internal/api/ -run TestWeb3 -count=1 -race — all 15 sub-tests green
  • go test ./... -count=1 -p 1 -race — full module suite green
  • gofmt -l internal/utilities/siwe/parser.go internal/utilities/siwe/parser_test.go — empty
  • go vet ./... — empty
  • Live before/after Docker roundtrip per the steps above (captured in PR conversation)

Operational note (out of scope; flagging for ops follow-up)

This PR prevents NEW duplicate identities from being created. Existing rows with mixed-case provider_id for provider='ethereum' are not migrated by this PR. Operators that have already accumulated duplicates may want a follow-up data migration along the lines of:

UPDATE auth.identities
   SET provider_id = lower(provider_id)
 WHERE provider = 'ethereum'
   AND provider_id <> lower(provider_id);

Plus a strategy for merging the duplicate user rows the case-variant identities point at. That coordination is outside the scope of a parser-level fix.

Why not also touch SIWS (Solana)

Solana addresses are base58-encoded and case-sensitive. Lowercasing them would corrupt valid wallet identifiers. The change is intentionally Ethereum-only.

…te identities

Ethereum addresses are protocol-level case-insensitive: 0xABC... and
0xabc... refer to the same wallet. EIP-55 mixed-case is a visual
checksum, not a distinct identifier.

Before this change, internal/utilities/siwe/parser.go stored the address
verbatim from the SIWE message body. internal/api/web3.go then composed
the identity provider_id directly from that string, and
models.FindIdentityByIdAndProvider does case-sensitive equality on
provider_id. The result: the same wallet signing in with different case
variants of its address produced two distinct auth.identities rows
pointing to two separate users. Fixes supabase#2264.

The fix is a single strings.ToLower call at parser entry, immediately
after the address pattern matches. Normalizing at the parser layer
gives every caller a single canonical form with no extra knowledge
required, and matches the existing email lowercase-normalization
precedent in internal/models/identity.go (BeforeUpdate). VerifySignature
already used strings.EqualFold, so signature recovery is unaffected.

Scope is intentionally limited to SIWE (Ethereum). The SIWS (Solana)
parser is left untouched: Solana addresses are base58, which is
case-sensitive, and lowercasing them would corrupt valid addresses.

Operational note: this fix prevents NEW duplicate identities. Existing
rows with mixed-case provider_id values are not migrated by this
patch; operators who have already accumulated duplicates will need a
separate backfill that lowercases auth.identities.provider_id for
provider='ethereum' rows and merges the resulting collisions. Flagged
as out of scope here.

Signed-off-by: Manas Srivastava <mastermanas805@gmail.com>
@mastermanas805 mastermanas805 marked this pull request as ready for review April 27, 2026 01:40
@mastermanas805 mastermanas805 requested a review from a team as a code owner April 27, 2026 01:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ethereum addresses with different cases create duplicate identities

1 participant