185 reads

Cross-Device Ethereum Login: Authenticate Desktop Users via MetaMask Mobile in PHP

by Laszlo FazekasJune 2nd, 2025
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

This article shows how to implement secure, passwordless login on a desktop website using MetaMask Mobile and PHP. Users scan a QR code, sign a challenge, and log in with their Ethereum wallet—offering strong security and a seamless experience.

Coins Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Cross-Device Ethereum Login: Authenticate Desktop Users via MetaMask Mobile in PHP
Laszlo Fazekas HackerNoon profile picture
0-item
1-item

Crypto wallets like MetaMask are primarily designed to manage crypto assets. However, they also serve as ideal tools for implementing passwordless login systems, since they provide the same strong level of security used to protect those digital assets.


In this article, I’ll demonstrate a solution that allows users to log in to a desktop website using the MetaMask app installed on their mobile device. Since the authentication happens on the mobile device, we inherently gain a level of security comparable to two-factor authentication. On top of that, the private key stored on the phone is protected by biometric identification. This approach is therefore not only significantly more secure than traditional password-based logins but also far more convenient—there’s no need to remember complex passwords.


From the user’s perspective, the authentication process is extremely simple. They just need to scan a QR code with their phone’s camera, which opens the MetaMask app. With a single tap, they sign a challenge message—and they’re logged in.


But why PHP? Although the language has lost some popularity in recent years, it’s still widely used. Countless websites run on WordPress, and many web applications are built with Laravel. MetaMask login can be easily integrated into these environments. That said, the specific programming language isn’t crucial. Once you understand the underlying logic of the system, it can be easily adapted to other platforms such as Node.js or Go.


You can find the full source code on GitHub:
👉https://github.com/TheBojda/php-metamask-login/


Let’s start with the desktop-side code. For simplicity, it uses SQLite, though in a production environment, it’s recommended to use MySQL or another full-featured SQL server.


At the beginning of the code, we create a sessions table to store user session data. The table structure is as follows:


CREATE TABLE IF NOT EXISTS sessions (
    session_id TEXT PRIMARY KEY,
    challenge TEXT,
    expiry DATETIME,
    eth_address TEXT DEFAULT NULL
)


  • session_id is a randomly generated session identifier.
  • challenge is a random string consisting of 2 × 4 characters. This is the message the user will sign in MetaMask.
  • expiry sets the time limit for how long the challenge is valid—10 minutes in the current implementation. The user must authenticate within this window; afterward, a new challenge must be generated.
  • eth_address stores the Ethereum address recovered from the digital signature, if the authentication is successful.

In the first part of the code, the system generates both the random challenge and session_id, then stores the session ID in the PHP session under the key metamask_session.


if (!isset($_SESSION['metamask_session'])) {
    $_SESSION['metamask_session'] = createMetaMaskSession();
} else {
    $stmt = $db->prepare("SELECT expiry FROM sessions WHERE session_id = :session_id");
    $stmt->execute([':session_id' => $_SESSION['metamask_session']]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$row) {
        // Session not found in DB, create new
        $_SESSION['metamask_session'] = createMetaMaskSession();
    } else {
        $expiry = DateTime::createFromFormat('Y-m-d H:i:s', $row['expiry']);
        $now = new DateTime();
        $interval = $now->diff($expiry);
        $minutesLeft = ($expiry > $now) ? ($interval->days * 24 * 60 + $interval->h * 60 + $interval->i) : -1;

        if ($expiry < $now || $minutesLeft <= 2) {
            $_SESSION['metamask_session'] = createMetaMaskSession();
        }
    }
}

function createMetaMaskSession()
{
    global $db;

    // Generate random session ID and challenge
    $sessionId = bin2hex(random_bytes(16)); // 32-char hex string
    $challenge = generateChallenge(8); // 8 uppercase letters
    $expiry = (new DateTime('+10 minutes'))->format('Y-m-d H:i:s');

    // Insert into database
    $stmt = $db->prepare("
        INSERT INTO sessions (session_id, challenge, expiry)
        VALUES (:session_id, :challenge, :expiry)
    ");
    $stmt->execute([
        ':session_id' => $sessionId,
        ':challenge'  => $challenge,
        ':expiry'     => $expiry,
    ]);

    return $sessionId;
}

function generateChallenge($length = 8)
{
    $letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
    $challenge = '';
    for ($i = 0; $i < $length; $i++) {
        $challenge .= $letters[random_int(0, 25)];
        if ($i == 3 && $length == 8) {
            $challenge .= ' ';
        }
    }
    return $challenge;
}

$sessionId = $_SESSION['metamask_session'];


In the index.phpHTML block, we generate a QR code that points to a URL in the format:
https://metamask.app.link/dapp/{$siteHost}/login.php/{$sessionId}

This is a deep link: when scanned with the phone’s camera, it opens the MetaMask app, which in turn launches its built-in browser and navigates to https://{$siteHost}/login.php/{$sessionId} where the authentication process takes place.


<!DOCTYPE html>
<html>

<head>
    <title>MetaMask Session</title>
    <script>
        setInterval(function() {
            window.location.reload();
        }, 10000);
    </script>
</head>

<body>
    <div style="display: flex; flex-direction: column; align-items: center; 
                  justify-content: center; min-height: 80vh;">
        <?php if ($ethAddress): ?>
            <h1>Logged in with MetaMask</h1>
            <p>Ethereum Address: <code><?= htmlspecialchars($ethAddress) ?></code></p>
        <?php else: ?>
            <h1>Your MetaMask Session</h1>
            <p>Session ID: <code><?= htmlspecialchars($sessionId) ?></code></p>
            <?php
            $siteHost = $_ENV['SITE_HOST'] ?? 'localhost';
            $url = "https://metamask.app.link/dapp/{$siteHost}/login.php/{$sessionId}";

            $builder = new Builder(
                writer: new PngWriter(),
                writerOptions: [],
                validateResult: false,
                data: $url,
                size: 300,
                margin: 10,
            );

            $result = $builder->build();

            header('Content-Type: text/html; charset=utf-8');
            $qrImage = base64_encode($result->getString());
            ?>
            <p>Scan this QR code with MetaMask mobile:</p>
            <img src="data:image/png;base64,<?= $qrImage ?>" alt="MetaMask QR Code" />
            <p><a href="<?= htmlspecialchars($url) ?>" target="_blank"><?= htmlspecialchars($url) ?></a></p>
        <?php endif; ?>
    </div>
</body>

</html>


As you can see from the code, the page refreshes itself every 10 seconds. Each time it loads, the PHP script checks whether an Ethereum address has been added to the session. If it has, the login is considered successful.


$ethAddress = null;
$stmt = $db->prepare("SELECT eth_address FROM sessions WHERE session_id = :session_id");
$stmt->execute([':session_id' => $_SESSION['metamask_session']]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row && !empty($row['eth_address'])) {
    $ethAddress = $row['eth_address'];
}


The login.php script handles signature verification and writes the Ethereum address into the session. Let’s take a look at how it works.


It receives the session ID from the URL path. Using this ID, it retrieves the corresponding challenge from the database.


// Get the session ID from the path info
$sid = null;
if (isset($_SERVER['PATH_INFO'])) {
    $parts = explode('/', trim($_SERVER['PATH_INFO'], '/'));
    if (isset($parts[0]) && $parts[0] !== '') {
        $sid = $parts[0];
    }
}
$challenge = null;
$error = null;

if ($sid) {
    try {
        $pdo = new PDO('sqlite:' . dirname(__DIR__) . '/metamask_sessions.db');
        $stmt = $pdo->prepare('SELECT challenge FROM sessions WHERE session_id = :sid');
        $stmt->execute([':sid' => $sid]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($row) {
            $challenge = $row['challenge'];
        }
    } catch (Exception $e) {
        $error = 'Database error.';
    }
} else {
    $error = 'No session ID provided.';
}


The digital signature is handled by JavaScript in the HTML portion of the page. Communication with MetaMask happens through the provider, which we access using the detectEthereumProvider utility from MetaMask. This provides a simple and compact integration.


The script retrieves the Ethereum account using the eth_requestAccounts call, and then requests the signature of the challenge using personal_sign. At this point, MetaMask displays a popup where the user simply needs to approve the signature.


The challenge to be signed is visible both on the desktop website and inside MetaMask, allowing the user to verify that they are signing the correct session challenge.


<script src="https://unpkg.com/@metamask/detect-provider/dist/detect-provider.min.js"></script>
<script>
            async function main() {
                const provider = await detectEthereumProvider();

                if (!provider) {
                    alert('This page should be opened in the MetaMask mobile app.');
                    return;
                }

                const challenge = <?php echo json_encode($challenge); ?>;
                const accounts = await provider.request({
                    method: 'eth_requestAccounts'
                });
                const from = accounts[0];

                try {
                    const signature = await provider.request({
                        method: 'personal_sign',
                        params: [challenge, from],
                    });
                    // Redirect to the same URL with the signature as a query parameter
                    const url = new URL(window.location.href);
                    url.searchParams.set('signature', signature);
                    window.location.href = url.toString();
                } catch (err) {
                    const url = new URL(window.location.href);
                    url.searchParams.set('signature', 'invalid');
                    window.location.href = url.toString();
                }
            }
            window.addEventListener('DOMContentLoaded', main)
</script>


If the signature is successful, the JavaScript sends the signature received from MetaMask to login.php via the signature parameter. The login.php script verifies the signature and extracts the Ethereum address using the signedMessageToAddress method.


$ethAddress = null;
if (isset($_GET['signature']) && $challenge) {
    $signature = $_GET['signature'];
    try {
        $ethAddress = Accounts::signedMessageToAddress($challenge, $signature);

        $db = new PDO('sqlite:' . dirname(__DIR__) . '/metamask_sessions.db');
        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

        $stmt = $db->prepare('SELECT expiry FROM sessions WHERE session_id = :sid');
        $stmt->execute([':sid' => $sid]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($row && isset($row['expiry'])) {
            $expiresAt = strtotime($row['expiry']);
            if ($expiresAt !== false && $expiresAt < time()) {
                $ethAddress = null;
                $error = 'Your session has expired.';
            }
        }

        if ($ethAddress) {
            $update = $db->prepare('UPDATE sessions SET eth_address = :eth_address WHERE session_id = :sid');
            $update->execute([
                ':eth_address' => $ethAddress,
                ':sid' => $sid
            ]);
        }
    } catch (Exception $e) {
        $error = 'Signature verification failed.';
    }
}


It then checks whether the session is still valid (i.e., the 10-minute window hasn't expired). If everything is in order, it stores the extracted Ethereum address in the session—completing the login process.


As mentioned earlier, index.php refreshes every 10 seconds and checks for the presence of the Ethereum address in the session. Once it finds the address, it confirms that the login was successful.


If you want to link Ethereum addresses to existing users (for example, on a Laravel or WordPress site that already has registered users), the process changes slightly to include a registration step. This step is very similar to the login process, but the session must also store the user's ID.


The user first logs in with their existing account. On their profile page, a QR code is displayed. The simplest approach is to include the userId in the generated sessionId, using a format like {random string}:{userId}. Then the regular login flow takes place.


When the Ethereum address is written into the session table, the system can extract the userId from the sessionId and store the Ethereum address in the corresponding user record in the database. After this registration step is complete, the user can log in using just their Ethereum address, since it can now be used to look up the associated account.


It’s also worth mentioning that there is an official Ethereum Sign-In standard defined by ERC-4361. The underlying logic is identical to what I’ve demonstrated here: the user signs a challenge with their Ethereum private key. The standard simply defines the format of the challenge message.


In my simplified solution, the challenge is a random string consisting of 2×4 characters, whereas the standard specifies a more structured message format. If needed, the implementation shown in this article can be easily extended to comply with the ERC-4361 standard.


As we've seen, logging in with MetaMask—or any other crypto wallet—is not only far more convenient than using outdated passwords, but also provides the strong security guarantees of modern crypto wallets. Let’s take advantage of the fact that we all carry a professional-grade hardware key in our pockets, secured with biometric authentication—our smartphones.

Trending Topics

blockchaincryptocurrencyhackernoon-top-storyprogrammingsoftware-developmenttechnologystartuphackernoon-booksBitcoinbooks