How To Integrate Passkeys In PHP Symfony Apps
Tags: tutorial, php, passkeys, symfony
Published: 2023-04-11 08:57:58

Passkeys are the new standard login method for web authentication but so far there are very few developer tutorials that show how to add them on a code level.

In the following, we will change the password-based login of our PHP Symfony app to a passwordless, passkey-first authentication with Corbado's web component.

The web component is an easy to integrate, lightweight authentication layer with great out-of-the-box passkey UX.

## Setup

{% embed https://github.com/vincentdelitz/tutorial-passkeys-php-symfony-webcomponent %}

Let's get the whole picture first! We use a dockerized setup with three containers:

1. The first container runs our PHP Symfony application
2. The second container hosts the database with our existing users.
3. The third container runs phpMyAdmin to provide easy access to the database

![System structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/250wbn095aaj6r9xsbbn.png)

Our folder structure looks like this

```
├── ...
├── config      
|   └── services.yaml               # Service configurations
├── src                             
│   ├── Controller                  
│   │   ├── WebhookController.php   # Manages endpoints for webhook
│   └── └── AppController.php       # Manages endpoints for application
├── templates                     
│   ├── home.html.twig              # Home page which you only get to see if you are logged in
│   ├── login.html.twig             # Login page which contains the Corbado web component; Acts as landing page if you are not logged in
├── .env                            # Contains all Symfony environment variables
└── ...
```

Start the system with `docker compose up`.
Our frontend runs on http://localhost:8000 while our backend is available at http://localhost:8000/api.

Our login page looks like this:

![Login page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/68j643oe94dl2lbqj0zi.png)

To integrate the Corbado web component, we follow these steps:

1. [Create project in the Corbado developer panel](#1-create-project-in-the-corbado-developer-panel)
2. [Integrate web component in the frontend](#2-integrate-the-corbado-web-component-in-the-frontend)
3. [Create backend endpoint to generate a session](#3-create-backend-endpoint-to-generate-a-session)
4. [(Optional) Enable password-based login as fallback](#4-optional-enable-passwordbased-login-as-fallback)
5. [(Optional) Make your local application reachable for Corbado via Corbado CLI](#5-make-your-local-application-reachable-for-corbado-via-corbado-cli)

## 1. Create project in the Corbado developer panel

We start by heading over to the [Corbado developer panel](https://app.corbado.com/signin#register) and create an account. We are welcomed by this screen

![Corbado developer panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/flw13m5202qxqyteulm4.png)

We click on 'Integration guide' to do everything step by step.

![Corbado developer panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bu1rhm5raa1jlfl6wpne.png)

We want to use the 'Web component', so that's what we select.

![Corbado developer panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2y2e9i55v757eidowg85.png)

Also, we have a system with an existing user base, so we click 'Yes'. Afterwards, we find ourselves at the overview of the developer panel (you need to confirm your account via email if it's your first time).

Head over to the 'Integration guide'. This guides us through all the steps necessary for the integration to work.


![Corbado developer panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4kvzh59vb5ledlc6zueg.png)

In step 1, we create an API secret. We need the project ID and the generated API secret later in order to communicate with Corbado's API. Also, we need to configure our authorized origin. The authorized origin is the browser URL from the site where the web component is integrated (with protocol and port but without path). We need it for CORS (cross-origin request sharing) and binding passkeys to it. In our case, we send it to http://localhost:8000.

![Corbado developer panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hqg80ieeirsme1rk48n3.png)

In the second, optional step, we configure the webhook. This is needed, so Corbado can communicate with our backend e.g. for checking if a username and password of an existing user match. More details on that later.

![Corbado developer panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4if2kemkwh9zyyrs67ik.png)

We will later set up our webhook at http://localhost:8000/corbado-webhook with ‘p36qLbyRvHoZDT’ and ‘z9aSidcu2xLyoU’ as credentials, so we can already enter that here.

In step 3, we add our Application URL and Redirect URL. The Application URL is the URL in the frontend where the web component runs. For example, it’s used to forward users to the web component again after they clicked on an email magic link.
The Redirect URL is the URL that receives Corbado’s session token as GET parameter, after Corbado has successfully authenticated a user, so that a session can be started. We implement our Redirect URL at http://localhost:8000/redirect later, but we can enter it here already.
The Relying Party ID is the domain where we bind our passkeys to. The domain in the browser where a passkey is used must be a matching domain to the Relying Party ID. As we test locally, we set the Relying Party ID to localhost.


![Corbado developer panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gz6loo9qa9ohbh3ae5yn.png)


Just like that, the project is set up!

## 2. Integrate the Corbado web component in the frontend

Now that we are done with the Corbado settings, let’s get to coding! In the frontend, we integrate the Corbado web component into our login page. Add the following, code in the HTML head, so it loads the JavaScript in advance:

```html
<script defer src="https://auth.corbado.com/auth.js"></script>
```

Afterwards, we replace our current login form with the one from Corbado (using your own project id of course):

```html
<corbado-auth project_id="<project ID>" conditional="yes"> 
  <input name="username" id="corbado-username" value="" required autocomplete="webauthn" autofocus/> 
</corbado-auth>
```

Taking a glance at our login page in the browser reveals the Corbado web component in action:

![Corbado web component](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1r0olarakawn98f18mdt.png)

Hint: If you now enter your email address and tried to sign up or login, you will get an error message. That's because we haven't configured the backend yet.

## 3. Create backend endpoint to generate a session

Once a user has successfully authenticated via the Corbado web component in the frontend, he gets sent to the Redirect URL with a corbadoSessionToken GET parameter. We configured this Redirect URL to be http://localhost:8000/redirect, remember? We implement this endpoint now. 

We’ll initialize the Corbado API client inside the services.yaml file. For this we need the project id and API secret from above.

```php
services:
    Corbado\Client:
        autowire: true
        bind:
            $baseURI: 'https://api.corbado.com'
            $username: '%app.projectID%'
            $password: '%app.apiSecret%'
```

We add a route for the required endpoint in our routeconfig (via annotation in our framework).

```php
#[Route(‘/api/redirect, name: success, methods: ‘GET’)]
```

In the redirect endpoint, we read the value of the corbadoSessionToken query parameter. Using the [Corbado PHP SDK](https://github.com/corbado/corbado-php), we then call Corbado’s sessionVerify API endpoint which will give us the email and name of the user which the token belongs to.

```php
Public function redirect(UserRepository $userRepo, Request $request, SessionInterface $session, Client $apiClient): Response {
  $token = $request->query->get(‘corbadoSessionToken’);
  $useragent = $request->headers->get(‘User-Agent’);
  $remoteAddress = $request->server->get(‘REMOTE_ADDR’);

  try {
    $result = $apiClient->widget()->sessionVerify($token, 
    $remoteAddress, $useragent);
  } catch (\Exception $e) {
    return new Response(‘Session token expired’, 400);
  }

  $response = $result->getData()->getUserData(); // Parse json response from Corbado request
  $userData = json_decode($response, true); $username = 
  $userData[‘username’];
  $userFullName = $userData[‘userFullName’]; // Create user if not exists
  $user = $userRepo->findOneBy([‘email’ => $username]);

  if ($user === null) {
    $user = new User($userFullName, $username);
    $userRepo->save($user, true);
  }
  $session->set(“userID”, $user->getId()); // Forward the user to frontend page

  return $this->redirectToRoute(‘home’);
}
```

We now know who just logged into our application. We create a session for the user and redirect him to a logged-in page of the app:

![Homepage](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d0y0jgjn9igu3w1yv8pm.png)

If you don't have an existing user base, that's all it takes to integrate passwordless, passkey-first authentication with Corbado!

## 4. (Optional) Enable password-based login as fallback

We don’t want to lock out our existing users, so we enable password-based authentication as a fallback. Therefore, Corbado needs to communicate with our backend. It does so via a webhook that is set up in our backend. In the steps before, we set the webhook URL to ‘http://localhost:8000/corbado-webhook’ as well as the webhook username and password. 

Corbado provides some PHP code that helps to add the webhooks:

`composer require corbado/webhook-php`

Then, we add the username and password of the webhook to our PHP Symfony environment variables, so that they are available when we want to authenticate an incoming webhook call:

```php
WEBHOOK_USERNAME=p36qLbyRvHoZDT
WEBHOOK_PASSWORD=z9aSidcu2xLyoU
```

Now we create a route for the webhook:

```php
# -------- Webhook --------
corbado:
  path: /corbado-webhook
  controller: App\Controller\WebhookController::corbado_webhook
```
as well as a controller to make the webhook available.

![Project files](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/akjj3rjqeycgdf2whdpf.png)

The webhook controller uses the previously installed Corbado webhook library and the route we protected via HTTP basic auth.

```php
class WebhookController extends AbstractController
{
  #[Route('/corbado-webhook', name: 'corbado', methods: 'POST')]
  public function corbadoWebhook(UserRepository $userRepo, Request $request, string $webhookUsername, string $webhookPassword): Response
{
  try { 
  // Create new webhook instance with username and password. Both must be
  // set in the developer panel (https://app.corbado.com) and are used to secure your
  // webhook (this one here) with basic authentication.
  $webhook = new Webhook($webhookUsername, $webhookPassword);
  // Handle authentication so your webhook is secured (basic authentication). If username
  // and/or password are invalid handleAuthentication() will send HTTP status code
// 401 (Unauthorized) and terminate/exit execution here. $webhook->handleAuthentication();
```
If a user enters their email in the web component, the webhook we just installed gets a call. The `corbado_webhook` function will handle it resulting in a call of the `userStatus` function. If the user exists, meaning he already has a password, Corbado gives him the option to login via password.

```php
function userStatus(UserRepository $userRepo, string $username): string
{
  $user = $userRepo->findOneBy(['email' => $username]);
  //Look up in database if $username exists and if $username is blocked/not permitted to login
  if ($user == null) {
    return AuthMethodsDataResponse::USER_NOT_EXISTS;
  } if ($user->isBlocked()) {
    return AuthMethodsDataResponse::USER_BLOCKED;
  } 
  return AuthMethodsDataResponse::USER_EXISTS;
}
```

Once the user has entered his password, another webhook call is issued resulting in a call to the `verifyPassword` function. There, we check our database if the given credentials match and send the response back.

```php
/**
* Verify given username and password.
* @param UserRepository $userRepo
* @param string $username
* @param string $password
* @return bool */
private function verifyPassword(UserRepository $userRepo, string $username, string $password): bool
{
  $user = $userRepo->findOneBy(['email' => $username]);
  if ($user == null || $user->getPassword() == null) {
    return false;
  }
  return password_verify($password, $user->getPassword());
}
```

Done! All our users can now use passkeys, while existing ones have their password as a fallback option.

But wait, how can Corbado call endpoints if our backend is only running locally, especially during development?

## 5. Make your local application reachable for Corbado via Corbado CLI

Using the Corbado CLI, we create a tunnel of our local application to the outside world, so that the webhook URL can be called. We install it by following the [docs]( https://docs.corbado.com/helpful-guides/corbado-cli)

![New system structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t3xlr1cakbecdy4hchju.png)

Once started, the webhook running on our machine forwards requests from Corbado to our local instance. Analog to the image above, the process is then as follows:
1.  The browser requests our login page
2.	The AppController sends back the HTML page which contains the web component.
3.	After the user has entered the email address, the web component sends it to Corbado.
4.	Corbado processes it and sends a request to our webhook to check if the user exists.
5.	The message gets forwarded through Corbado CLI in order to reach our local instance. (If your solution is in production, Corbado can directly call the Webhook controller without the need for the Corbado CLI tunnel because you then have a public address).
6.	Our webhook controller sends back the requested information.
7.	The message gets forwarded through Corbado CLI again 
8.	Corbado tells the web component how to react (e.g., ask the user for a password).

Afterwards, we head to the [Corbado developer panel](https://app.corbado.com/app/settings/credentials/cli-secret) to copy our CLI secret.

We login using the project ID and CLI secret:

`corbado login --projectID pro-1987097639455463555 --cliSecret 4uKn...`

Once logged in, we can start our tunnel using the subscribe command (with port 8000 as our Symfony application is running here).

`corbado subscribe http://localhost:8000 --projectID pro-1987097639455463555 --cliSecret 4uKn...`

Now, we can test if our webhook works by going to https://app.corbado.com/app/settings/webhooks/testing, filling out the stub data and running the tests. If everything went right, all tests will pass.

![Corbado developer panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sgn02ahr86z43sflwo40.png)

If we now go to http://localhost:8000/login and enter the email of an already existing account, the web component should ask you for a password (if no passkey for the account has been created yet).

![Corbado web component](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fzw7vm65p77vii00vpbq.png)

Afterwards, users are asked to create a passkey for future logins.