Skip to main content

How to Build a One-Click Sign in Using MetaMask with PHP's Laravel

Created on
Updated on
Dec 17, 2024

12 min read

Overview​

Social logins: we have all seen them, we have all used them. "Login with Facebook". "Login with Github".

If you have been around the Web3 community you may have come across a more recent player in the game: "Login with MetaMask". For instance, this is how you may sign up for OpenSea the biggest NFT marketplace for Ethereum and Polygon.

MetaMask is a crypto wallet for the Ethereum blockchain that also allows you to interact with dApps (decentralized apps). Specifically it allows dApps to verify that you are the owner of a certain Ethereum address which in turn will serve as your online identity.

This tutorial will show you how to implement a one-click MetaMask login using web3.js and a PHP backend. While we will be using Laravel and Vue in this tutorial, the principles of:

  • Signing a message in Javascript using web3.js, and
  • Verifying the signature in a PHP backend

…is absolutely transferable to any JS and PHP framework of your choice.

Prerequisites:
In order to follow along the steps in this tutorial you will need:

  • MetaMask installed as a browser extension with at least 1 account (no ETH needed!). If you do not already have MetaMask installed you may take a look at their website. — If you want to configure your QuickNode RPC to MetaMask, we have you covered there too.
  • A local development environment that allows you to run a fresh Laravel installation. Take a look at the excellent Laravel documentation to get started. In this tutorial I will be using the Installation Via Composer installation method, but feel free to use Laravel Sail (Docker) should you prefer. When using Laravel Sail you will need prefix all commands with ./vendor/bin/sail (ie ./vendor/bin/sail composer install instead of composer install); please refer to the official Laravel Sail documentation for more information on how to execute commands.
  • General knowledge and familiarity of running terminal commands (NPM/Composer installs)

We have the source code here as well for you to look at.

Setting Up the Project​

Alright, let us get going!

First off, we will create a new Laravel project. As previously mentioned I will be using the composer create-project method. This works great if you already have PHP and Composer installed on your local machine. Checkout the official Laravel documentation for more available installation options.

Run the following command in your terminal to generate the project:

composer create-project laravel/laravel metamask-demo-app

Install Jetstream​

To get a head start on the frontend, we will pull in the official Laravel package "Jetstream" which gives us a nice pre-baked dashboard that includes a login form!

Inside your newly created folder metamask-demo-app you may run:

composer require laravel/jetstream

Once installed, we will tell Jetstream to scaffold our application with the Inertia (Vue3) preset. This will include a stack of Vue 3 and Tailwind CSS.

php artisan jetstream:install inertia

Finally, let us install the newly added NPM dependencies and compile the assets:

npm install && npm run dev

Up and running​

In order to get the new Laravel app up and running, we will need to add a database connection. Normally this would be a MySQL or PostgreSQL database, but for our demo purpose we can use a SQLite database by creating an empty file called database.sqlite in our database directory.

To do so, run the following command from the root of your project:

touch database/database.sqlite

We will also need to update our .env file to use the sqlite connection and comment out or remove the unused variables (important):

DB_CONNECTION=sqlite
#DB_HOST=
#DB_PORT=
#DB_DATABASE=
#DB_USERNAME=
#DB_PASSWORD=

Finally, we can migrate our database migrations and serve our Laravel application to the browser!

php artisan migrate
php artisan serve

The last command should output something like:

Starting Laravel development server: http://127.0.0.1:8000

Opening that URL in your browser, you should be met with a default Laravel welcome screen.

Navigate to http://127.0.0.1:8000/login and you should see a Jetstream login form.

Congratulations 🎉 — we are now ready to start coding!

Preparing the Frontend​

First things first — let us tweak the frontend a bit by adding our "Login with MetaMask button".

Open the file resources/js/Pages/Auth/Login.vue and add the following HTML after the logo template part (around line 5).

 between the root part

// resources/js/Pages/Auth/Login.vue

<div class="text-center pt-4 pb-8 border-b border-gray-200">
<jet-button @click="loginWeb3">
Login with MetaMask
</jet-button>
</div>
<div class="py-6 text-sm text-gray-500 text-center">
or login with your credentials…
</div>

As you may have noticed the button already has a click handler specified, for now add a new empty method called loginWeb3 after the existing submit method:

at the bottom between the <script> tags, add async loginWeb3 method

// resources/js/Pages/Auth/Login.vue
methods: {
submit() {
// ...
},
async loginWeb3() {
// Our Meta Mask integration goes here
}
}

If you compile it by running npm run dev in your terminal the result should look something like this:

Creating a Signature Request​

Right now the button does not do anything. Let us fix that!

We will start by installing the NPM package web3 which we will need:

npm install web3

Now we are ready to fill in a bit of logic.

First, add these imports at the top of the <script> section:

At the top of the <script> section

// resources/js/Pages/Auth/Login.vue
import Web3 from 'web3/dist/web3.min.js'
import { useForm } from '@inertiajs/inertia-vue3'

Next update the empty loginWeb3 function so it looks like the following:

Replace the previously added async loginWeb3 method

// resources/js/Pages/Auth/Login.vue
async loginWeb3() {
if (! window.ethereum) {
alert('MetaMask not detected. Please try again from a MetaMask enabled browser.')
}

const web3 = new Web3(window.ethereum);

const message = [
"I have read and accept the terms and conditions (https://example.org/tos) of this app.",
"Please sign me in!"
].join("\n")

const address = (await web3.eth.requestAccounts())[0]
const signature = await web3.eth.personal.sign(message, address)

return useForm({ message, address, signature }).post('/login-web3')
}

Here is what we're doing in the above code:

  1. Ensure MetaMask is present in the current browser by checking the window.ethereum property is present. Otherwise alert the user. (lines 3-5)
  2. Prepare the message we want the user to sign. We might as well make this a bit useful, such as have the user accepting the Terms & Conditions for using the app. However it is absolutely up to you what you'd like to have the user sign. (lines 9-12)
  3. Request the user's accounts. This is the first popup our users will see, and they have to select which address to sign in with. (line 14)
  4. Make the user sign our message - in this case accept our T&C. (line 15)
  5. Finally, send the message, address & signature to the backend. (line 17)

With a little bit of luck, after compilation you should be able to see the following flow in you browser:

Quick aside:​

When it comes to the signature message, we have found that there is a little quirk to beware of. At the time of writing, if you enter any message of exactly 32 characters, the message will be presented in HEX in MetaMask. As such the message "Hello world but a lil bit longer" becomes:

In reality it is not a huge problem, but in my case it ended up costing me a bit of extra hours and hair-pulling figuring why my message was not showing in plain text. Now you know!

Verifying the Signature​

We have our signature ready, and we are sending it to the backend. Now we need to:

  • Verify the signature is authentic
  • Check if the address matches an existing user in our database or otherwise create a new user
  • Log the user in and redirect to the app dashboard

Let us get started.

Install dependencies​

Before we get to the actual coding part, we will need to install a few dependencies.

composer require kornrunner/keccak --ignore-platform-reqs
composer require simplito/elliptic-php --ignore-platform-reqs

We are adding the --ignore-platform-reqs flag as composer would otherwise throw an error stating that the required ext-gmp extension is missing.

For this demo to work, the GMP extension is not required, and we can safely ignore it.

However, should you for any reason wish to install GMP anyway, you may find this gist helpful.

Prepare the users table​

Open the database/migrations/2014_10_12_000000_create_users_table.php file and replace the up method with the following code:

# database/migrations/2014_10_12_000000_create_users_table.
public function up()
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('eth_address')->nullable();
$table->string('name')->nullable();
$table->string('email')->unique()->nullable();
$table->timestamp('email_verified_at')->nullable();
$table->string('password')->nullable();
$table->rememberToken();
$table->foreignId('current_team_id')->nullable();
$table->string('profile_photo_path', 2048)->nullable();
$table->timestamps();
});
}

The changes we have made here are:

  • Added a eth_address field which will hold the user's ethereum address
  • Made name , email , email_verified_at and password nullable

Remember to refresh the database afterwards using:

php artisan migrate:fre

The login logic​

Open the routes/web.php file and add the following line:

Add in the bottom of the file - after the Route::middleware(['auth:sanctum', 'verified'])->get(...) part

# routes/web.
Route::post('login-web3', \App\Actions\LoginUsingWeb3::class);

*Note**: We are using the routes/web.php file and not api.php because we will need access to the sesssion / cookie state in order to log in the user once authenticated.*

Next let us go on and create the app/Actions/LoginUsingWeb3.php file which will hold the actual login logic. Copy / paste the following code into it: 

# app/Actions/LoginUsingWeb3.
<?

namespace App\Actions;

use App\Models\User;
use Illuminate\Http\Request;
use Elliptic\EC;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use kornrunner\Keccak;

class LoginUsingWeb3
{
public function __invoke(Request $request)
{
if (! $this->authenticate($request)) {
throw ValidationException::withMessages([
'signature' => 'Invalid signature.'
]);
}

Auth::login(User::firstOrCreate([
'eth_address' => $request->address
]));

return Redirect::route('dashboard');
}

protected function authenticate(Request $request): bool
{
return $this->verifySignature(
$request->message,
$request->signature,
$request->address,
);
}

protected function verifySignature($message, $signature, $address): bool
{
$messageLength = strlen($message);
$hash = Keccak::hash("\x19Ethereum Signed Message:\n{$messageLength}{$message}", 256);
$sign = [
"r" => substr($signature, 2, 64),
"s" => substr($signature, 66, 64)
];

$recId = ord(hex2bin(substr($signature, 130, 2))) - 27;

if ($recId != ($recId & 1)) {
return false;
}

$publicKey = (new EC('secp256k1'))->recoverPubKey($hash, $sign, $recId);

return $this->pubKeyToAddress($publicKey) === Str::lower($address);
}

protected function pubKeyToAddress($publicKey): string
{
return "0x" . substr(Keccak::hash(substr(hex2bin($publicKey->encode("hex")), 1), 256), 24);
}
}

There is a few things going on here, so let us break it down step-by-step.

The _invoke method​

This is the entry-point for the route that we registered, and will receive the POST request sent from our frontend.

The actual logic is quite straight forward as we:

  • Validate the signature sent from the frontend
  • Find or create new user based on the user's address. When creating a new user, we will make sure to store the address in our dedicatedeth_address field.
  • Log the user in, and redirect to the Jetstream dashboard

The verifySignature method​

This is a standardized way of cryptographically validating that an Ethereum signature matches the corresponding message and address that signed it.

It is functionally equivalent to web3.eth.personal.ecRecover which returns the signing address of a message + signature.

We will not fully go into the nitty gritty details of this code, (I am not a mathematician, and you do not need to be one either) but the high level explanation is that we are:

  • Reconstructing a hash of the message
  • Extracting the public key from the signature and hashed message
  • Extracting the address from the public key
  • Checking that the address sent from the frontend actually matches the address that signed the message

And voilá! We now have a functioning login!

If you go back to your browser and go through the login flow, you should now be redirected to the dashboard.

Bonus tip: disable Jetstream registration​

If you actually intend to use Jetstream for your app and want to make sure your users always register with MetaMask the first time they login, you may wish to disable the default /register route that Jetstream ships with.

You can open config/fortify.php and comment out the "registration feature" line:

# config/fortify.
'features' => [
// Features::registration(),
Features::resetPasswords(),
[...]
],

Your users will now only be able to register using the MetaMask login.

Conclusion​

That is it folks!

In this tutorial we have built a simple login flow using Vue, Web3, MetaMask and Laravel.

I hope it has conveyed the basic workflow of how to integrate with a wallet, and how to use signatures to securely authenticate a user's identity server-side as well.

Given the decentralized nature of Web3, you will probably want to support more wallets that just MetaMask. For instance adding WalletConnect would allow using mobile-wallets to sign in by scanning a QR code.

For further exploration into supporting more wallets, you may wish check out these resources:

Subscribe to our newsletter for more articles and guides on Ethereum. If you have any feedback, feel free to reach out to us via Twitter. You can always chat with us on our Discord community server, featuring some of the coolest developers you will ever meet :)

Share this guide