Search by

aporat / laravel-firebase-app-check

aporat

Firebase App Check for Laravel APIs: a middleware that verifies the X-Firebase-AppCheck token per request with soft and hard enforcement and cached verdicts, a Firebase ID-token sign-in verifier, projects keyed by app name, and fakes for tests

Package info

github.com/aporat/laravel-firebase-app-check

pkg:composer/aporat/laravel-firebase-app-check

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

0.1.0 2026-10-20 15:44 UTC

This package is auto-updated.

Last update: 2026-10-20 15:50:30 UTC


README

Firebase App Check for Laravel APIs: a middleware that verifies the X-Firebase-AppCheck token every mobile request carries, with soft and hard enforcement, cached verdicts and sane behaviour when Firebase is down; a verifier for the Firebase Authentication ID token your sign-in endpoint receives; projects keyed by app name for APIs that serve several apps; and fakes for tests.

Latest Stable Version Downloads codecov Laravel Version GitHub Actions License

โœจ Features

  • firebase-app-check middleware. Verifies the token against the project the request belongs to through the Firebase Admin SDK. Reads (GET, HEAD) are soft by default: a missing or failed token is still served, because a token goes missing on perfectly genuine installs (Play Integrity quota, a device without Play Services, a cold start that raced the asynchronous token fetch) and refusing those callers a read helps nobody. Every other method is hard: rejected and reported. Any route can opt into soft mode with firebase-app-check:soft, meant for the launch path
  • Verdicts are cached, keyed by project and token hash. A good token is honoured for its own remaining life, capped at an hour; a bad token is remembered for a minute. An outage caches nothing, so the next request retries instead of inheriting it. The RSA verification runs once per token, not once per request
  • Multi-app. Projects are keyed by whatever names the calling app, usually a header like X-App-Name; two apps sharing a Firebase project alias one entry. Credentials come as a key file path, the key file's JSON (straight from a secrets manager) or the decoded array
  • Rejections are a 404 by default, so a caller probing the API cannot tell an attestation failure from a missing route. Status, body and the log line are all replaceable, and skipWhen() exempts whatever you need: a sandbox API level, an internal caller
  • IdTokenVerifier for sign-in: hands you an Identity (uid, email, phone number, provider, claims) from a Firebase ID token, with a clear split between a token the caller should fix (InvalidIdToken, a 401) and a check that could not run (IdTokenVerificationFailed, a 5xx)
  • AppCheck::project($name) gives you the Admin SDK factory for anything else: deleting a Firebase user when the account is deleted, say
  • AppCheck::fake() and IdTokenVerifier::fake() so feature tests never reach Firebase

๐Ÿ›  Installation

composer require aporat/laravel-firebase-app-check

Requires PHP 8.4+ and Laravel 12 or 13. Publish the config:

php artisan vendor:publish --tag=config --provider="Aporat\FirebaseAppCheck\FirebaseAppCheckServiceProvider"

Point it at a service account. Download the Admin SDK key from the Firebase console (Project settings โ†’ Service accounts) and either give the path or paste the JSON into .env:

FIREBASE_CREDENTIALS=/var/secrets/firebase-adminsdk.json

Then protect routes:

Route::middleware('firebase-app-check')->group(function () {
    Route::get('account', [AccountController::class, 'show']);
    Route::post('account', [AccountController::class, 'update']);
});

Install symfony/cache as well if you want Google's public signing keys cached in your Laravel cache store between requests; without it the SDK keeps them in memory for the life of the process.

โš™๏ธ Configuration

return [
    // One entry per name a request can resolve to. A string value is an
    // alias for another entry.
    'projects' => [
        'default' => ['credentials' => env('FIREBASE_CREDENTIALS')],
    ],

    // Which project a request belongs to: the header's value when present,
    // else the default. See "Several apps" below.
    'project' => [
        'header' => env('FIREBASE_APP_CHECK_PROJECT_HEADER'),
        'default' => env('FIREBASE_APP_CHECK_PROJECT', 'default'),
    ],

    // Where the Firebase client SDKs put the token.
    'header' => 'X-Firebase-AppCheck',

    // Methods served even when the token is missing or invalid.
    'soft_methods' => ['GET', 'HEAD'],

    // The verdict cache: store (null for the default), key prefix, how long
    // a good token is honoured at most, how long a bad one is remembered.
    'cache' => [
        'store' => env('FIREBASE_APP_CHECK_CACHE_STORE'),
        'prefix' => 'firebase-app-check',
        'verified_ttl' => 3600,
        'failed_ttl' => 60,
    ],

    // The HTTP status of a rejection.
    'response' => ['status' => 404],

    // Where and how loudly hard rejections are logged.
    'log' => [
        'channel' => env('FIREBASE_APP_CHECK_LOG_CHANNEL'),
        'level' => 'error',
    ],

    // ID-token verification: tolerated clock skew, and whether to ask
    // Firebase if the token was revoked (one extra call per sign-in).
    'id_tokens' => [
        'leeway' => 60,
        'check_revoked' => false,
    ],
];

๐Ÿšฆ Enforcement

The middleware decides per request:

Situation Hard route Soft route or soft method
Token verified served served
No token rejected, reported served, silent
Token invalid (expired, wrong app, malformed) rejected with Firebase's reason, reported served, silent
Firebase unreachable rejected with a generic message, reported served, silent
Request names no project, or an unconfigured one rejected, reported rejected, reported

The last row is deliberate: an unknown project is a configuration problem, not an attestation one, and serving it would hide the misconfiguration.

Soft-served failures are not logged at all. Every cold start and every unattested read would otherwise land on the error channel, and that volume buries the rejections that need eyes.

The launch path

The first requests an app sends on a cold start (configure, fetch the account, register the push token) are often POSTs, and they race the Firebase SDK's asynchronous token fetch. Hard-failing them protects no state worth protecting; it bricks the launch. Take them as soft:

Route::middleware('firebase-app-check:soft')->group(function () {
    Route::post('configure', ConfigureController::class);
    Route::post('account/register-push', [AccountController::class, 'registerPush']);
});

Route::middleware('firebase-app-check')->group(function () {
    // everything that changes state
});

Hooks

Register these in a service provider's boot():

use Aporat\FirebaseAppCheck\Facades\AppCheck;
use Aporat\FirebaseAppCheck\Failure;

// Serve a request without looking at its token.
AppCheck::skipWhen(fn (Request $request): bool => $request->header('X-Auth-Version') === '300');

// Decide the project yourself instead of header-then-default.
AppCheck::resolveProjectUsing(fn (Request $request): ?string => $request->route('app'));

// Shape the rejection. $failure is a Failure case; $message is what the
// caller may be told (Firebase's own reason for an invalid token, a
// generic line otherwise).
AppCheck::rejectUsing(fn (Request $request, Failure $failure, string $message): Response => response()->json([
    'error_code' => 404,
    'error_type' => 'app_check_failed',
    'error_message' => $message,
], 404));

// Replace the log line. $exception is set for an invalid token or an
// outage, null for a missing token or an unknown project.
AppCheck::reportUsing(function (Request $request, Failure $failure, ?string $message, ?Throwable $exception): void {
    Log::channel('security')->error('App Check rejected', ['failure' => $failure->value, 'path' => $request->path()]);
});

Failure has four cases: UnknownProject, MissingToken, InvalidToken, VerificationError. isCallerFault() is true for the middle two.

๐Ÿ“ฑ Several apps

An API serving more than one app has more than one Firebase project. Key the projects by whatever header names the app and tell the middleware to read it:

'projects' => [
    'PhotosIOS' => ['credentials' => env('PHOTOS_FIREBASE_SERVICE_ACCOUNT_JSON')],
    'PhotosAndroid' => 'PhotosIOS',
    'NotesIOS' => ['credentials' => env('NOTES_FIREBASE_SERVICE_ACCOUNT_JSON')],
],

'project' => [
    'header' => 'X-App-Name',
    'default' => null,
],

A request with X-App-Name: PhotosAndroid is verified against the Photos project. Verdicts are cached per project, so a token verified for one app never vouches for another.

๐Ÿ” Sign-in with an ID token

Have the app post the ID token it got from the Firebase SDK instead of a raw uid, email or phone number, and let the server prove the identity:

use Aporat\FirebaseAppCheck\Auth\IdTokenVerifier;
use Aporat\FirebaseAppCheck\Exceptions\IdTokenVerificationFailed;
use Aporat\FirebaseAppCheck\Exceptions\InvalidIdToken;

public function signIn(Request $request, IdTokenVerifier $verifier): JsonResponse
{
    try {
        $identity = $verifier->verify('PhotosIOS', $request->string('id_token'));
    } catch (InvalidIdToken $e) {
        abort(401, 'Your sign-in could not be verified. Please sign in again.');
    } catch (IdTokenVerificationFailed $e) {
        abort(503, 'We could not verify your sign-in right now. Please try again.');
    }

    $identity->uid;            // the Firebase uid
    $identity->email;          // null unless the provider supplied one
    $identity->phoneNumber;    // likewise
    $identity->signInProvider; // 'apple.com', 'google.com', 'phone', 'password', ...
    $identity->emailVerified();
    $identity->claims;         // everything else
}

Anything else the Admin SDK does

use Aporat\FirebaseAppCheck\Facades\AppCheck;

AppCheck::project('PhotosIOS')->createAuth()->deleteUser($uid);
AppCheck::hasProject('PhotosIOS');
AppCheck::projects()->projectId('PhotosIOS');

๐Ÿงช Testing

use Aporat\FirebaseAppCheck\Auth\Identity;
use Aporat\FirebaseAppCheck\Auth\IdTokenVerifier;
use Aporat\FirebaseAppCheck\Facades\AppCheck;

// Every token is accepted, every project exists, nothing reaches Firebase.
$appCheck = AppCheck::fake();
$appCheck->rejecting('expired', 'The token is expired.');
$appCheck->failing('outage');          // Firebase unreachable for this token
$appCheck->acceptOnly('good');         // everything else is now rejected

$this->postJson('/account', [], ['X-Firebase-AppCheck' => 'good'])->assertOk();

$appCheck->assertVerified('good');
$appCheck->assertVerified('good', 'PhotosIOS');
$appCheck->assertNothingVerified();

// Sign-in without a Firebase project.
$verifier = IdTokenVerifier::fake(['valid-token' => new Identity('uid-1', 'user@example.com')]);
$verifier->failing('down');           // IdTokenVerificationFailed for this token

$this->postJson('/sign-in', ['id_token' => 'valid-token'])->assertOk();

$verifier->assertVerified('valid-token');

Any token the ID-token fake was not given is rejected with InvalidIdToken.

๐Ÿ“„ License

MIT. See LICENSE.