Verify Firebase auth JWT with PHP Laravel

Aug 22, 2021 PHPFirebaseLaravel

A few months ago, I wrote about how to verify Firebase auth JWT using Ruby. Similarly, Firebase doesn't provide an official SDK for PHP. In this post, I will walk you through how to decode and verify Firebase JWT using PHP. It will use a few Laravel classes for caching, http client, and string manipulations, but the approach I will show here is framework-agnostic.

Overall flow

  1. The client app retrieves the Firebase ID Token (JWT) and send it to the backend server through Authorization HTTP header
  2. The backend server decodes and verifies the JWT following this guide

I'm not going to cover how to retrieve the token on the client side since it's very straightforward. This post is focused on decoding and verifying JWT on the backend.

TL;DR

I created the FirebaseToken class with verify() method that verifies JWT and returns the docoded payload which contains the authenticated user data. You can find the full codebase here. Let me explain how it works step by step.

Install firebase/php-jwt package

First, let's install firebase/php-jwt which we will use for decoding and verifying JWT.

$ composer require firebase/php-jwt

FirebaseToken class

Next, create the FirebaseToken class. The constructor takes the $token which is the actual JWT.

/**
 * Firebase ID token.
 *
 * @var string
 */
private string $token;

/**
 * @param string $token
 */
public function __construct(string $token)
{
    $this->token = $token;
}

Fetch public keys for verifying JWT

In the verify() method, we will use the JWT::decode() that firebase/php-jwt provides. It takes public keys as the second argument which will be used for verifying token was signed by the right private key.

/**
 * Verify the ID token and return the decoded payload.
 *
 * @param string $projectId
 * @return object
 * @throws UnexpectedValueException|Exception
 */
public function verify(string $projectId): object
{
    $keys = $this->getPublicKeys();

    $payload = JWT::decode($this->token, $keys, self::ALLOWED_ALGOS);

    $this->validatePayload($payload, $projectId);

    return $payload;
}

Grab the public key from here and pass it to JWT::decode() method. As you can see, it caches the response (i.e. public keys) to avoid downloading it every time. For the cache TTL, use the max-age in the Cache-Control header of the response.

/**
 * Fetch JWT public keys.
 *
 * @return array
 * @throws Exception
 */
private function getPublicKeys(): array
{
    if (Cache::has(self::CACHE_KEY)) {
        return Cache::get(self::CACHE_KEY);
    }

    $response = Http::get(self::PUBLIC_KEY_URL);

    if (!$response->successful()) {
        throw new \Exception('Failed to fetch JWT public keys.');
    }

    $publicKeys = $response->json();
    $cacheControl = $response->header('Cache-Control');
    $maxAge = Str::of($cacheControl)->match('/max-age=(\d+)/');

    Cache::put(self::CACHE_KEY, $publicKeys, now()->addSeconds($maxAge));

    return $publicKeys;
}

Decode and verify JWT

Now, let's decode and verify the JWT calling the JWT::decode() method.

$payload = JWT::decode($this->token, $keys, self::ALLOWED_ALGOS);

The third argument is an array of signing algorithms used in the JWT. Firebase uses RS256 for signing JWT.

/**
 * The list of allowed signing algorithms used in the JWT.
 *
 * @var array
 */
const ALLOWED_ALGOS = ['RS256'];

We need to verify the header and payload following Firebase's official guide. JWT::decode() verifies most of the attributes (see here for details), but we need to verify aud, iss, and sub manually like below.

/**
 * Validate decoded payload.
 *
 * @param object $payload
 * @param string $projectId  Firebase project id
 * @return void
 * @throws UnexpectedValueException
 *
 * @see https://firebase.google.com/docs/auth/admin/verify-id-tokens#verify_id_tokens_using_a_third-party_jwt_library
 */
private function validatePayload(object $payload, string $projectId): void
{
    if ($payload->aud !== $projectId) {
        throw new UnexpectedValueException("Invalid audience: {$payload->aud}");
    }

    if ($payload->iss !== "https://securetoken.google.com/{$projectId}") {
        throw new UnexpectedValueException("Invalid issuer: {$payload->iss}");
    }

    // `sub` corresponds to the `uid` of the Firebase user.
    if (empty($payload->sub)) {
        throw new UnexpectedValueException('Payload subject is empty.');
    }
}

Let's use it!

Done! Finally, we can use the FirebaseToken class to decode and verify Firebase JWT.

// Retrieve Authorization header
$token = $request->bearerToken();

$payload = (new FirebaseToken($token))->verify(
    config('services.firebase.project_id')
);

$payload->user_id;

I added the following config in config/services.php.

'firebase' => [
    'project_id' => env('FIREBASE_PROJECT_ID'),
]

You can use it for your custom auth guard in your Laravel application. The full codebase can be found here.

If you've got any questions or comments, please comment in this Twitter thread!

avatar

About Me

I am a software engineer from Japan and currently based in Singapore. Over the past 5 years, I've been creating e-commerce and FinTech web applications using Laravel, Angular, and Vue.js. Making clean, maintainable, and scalable software with agile methodology is one of my biggest passions. Im my spare time, I play the bass and enjoy DIY.