log in
consulting hosting industries the daily tools about contact

Clerk JWT Claims in Laravel: The SDK-Less Way That Actually Works

Clerk's frontend SDKs are slick, but the backend story for Laravel is 'bring your own.' Here's exactly how I wired it without surprises.

Clerk has no official Laravel SDK. That's not a complaint — it's just a fact you should know before you commit to it on a PHP project. What Clerk does have is a well-documented JWT-based session model, a public JWKS endpoint, and enough rope to hang yourself if you skip a few steps. I learned most of what's below by hanging myself first.

What Clerk Actually Solves

Clerk is a managed auth platform: hosted sign-in/sign-up UI, social OAuth, MFA, organizations, user management dashboard, the works. You drop their frontend SDK into your React or Next.js app and authentication becomes someone else's problem. The sessions it issues are short-lived JWTs — signed with RS256 using a keypair Clerk rotates on your behalf.

The pitch to me was a real estate portal project. The frontend was Next.js; the backend was a Laravel API I was already running. The client wanted social login and org-level access control without building any of it. Clerk handled all of that on the frontend. My job was making sure the Laravel API could validate the tokens Clerk issued.

There's no composer require clerk/sdk that does this for you. You're validating a JWT against a JWKS endpoint. That's a solved problem — the pieces just aren't assembled for you.

The Wiring

Here's the approach. Clerk exposes a JWKS endpoint at https://<your-frontend-api>.clerk.accounts.dev/.well-known/jwks.json. Your Laravel API fetches the public keys, validates the incoming Authorization: Bearer <token> header, and maps the claims into something your app can use.

I lean on firebase/php-jwt for the heavy lifting. It handles RS256 and JWKS key parsing cleanly.

composer require firebase/php-jwt

Then a dedicated service class:

<?php

namespace App\Services;

use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;

class ClerkTokenValidator
{
    private string $jwksUrl;
    private string $issuer;

    public function __construct()
    {
        // e.g. https://your-app.clerk.accounts.dev
        $this->issuer  = config('clerk.issuer');
        $this->jwksUrl = $this->issuer . '/.well-known/jwks.json';
    }

    public function validate(string $token): object
    {
        $keys = $this->fetchKeys();

        try {
            $decoded = JWT::decode($token, JWK::parseKeySet($keys));
        } catch (\Throwable $e) {
            throw new RuntimeException('Invalid Clerk token: ' . $e->getMessage(), 401, $e);
        }

        $this->assertClaims($decoded);

        return $decoded;
    }

    private function fetchKeys(): array
    {
        return Cache::remember('clerk_jwks', 600, function () {
            $response = Http::timeout(5)->get($this->jwksUrl);

            if ($response->failed()) {
                throw new RuntimeException('Could not fetch Clerk JWKS');
            }

            return $response->json();
        });
    }

    private function assertClaims(object $claims): void
    {
        if (($claims->iss ?? '') !== $this->issuer) {
            throw new RuntimeException('Token issuer mismatch', 401);
        }

        if (($claims->azp ?? '') !== config('clerk.authorized_party')) {
            throw new RuntimeException('Token authorized party mismatch', 401);
        }
    }
}

The middleware that calls it:

<?php

namespace App\Http\Middleware;

use App\Services\ClerkTokenValidator;
use Closure;
use Illuminate\Http\Request;
use RuntimeException;

class VerifyClerkToken
{
    public function __construct(private ClerkTokenValidator $validator) {}

    public function handle(Request $request, Closure $next): mixed
    {
        $token = $request->bearerToken();

        if (! $token) {
            return response()->json(['error' => 'Unauthenticated'], 401);
        }

        try {
            $claims = $this->validator->validate($token);
        } catch (RuntimeException $e) {
            return response()->json(['error' => $e->getMessage()], 401);
        }

        // Stash the claims for use in controllers
        $request->attributes->set('clerk_claims', $claims);
        $request->attributes->set('clerk_user_id', $claims->sub);

        return $next($request);
    }
}

Register it in bootstrap/app.php (Laravel 11) or Kernel.php (Laravel 10 and earlier), then apply it to your API routes:

Route::middleware(['verify.clerk'])->group(function () {
    Route::get('/me', fn (Request $r) => response()->json([
        'user_id' => $r->attributes->get('clerk_user_id'),
    ]));
});

And the config file at config/clerk.php:

<?php

return [
    'issuer'             => env('CLERK_ISSUER'),
    'authorized_party'   => env('CLERK_AUTHORIZED_PARTY'),
];

CLERK_ISSUER is your Clerk frontend API URL. CLERK_AUTHORIZED_PARTY is your frontend's origin URL — more on why that matters in a second.

The Gotchas

The azp claim will break you if you ignore it. Clerk's JWTs include an azp (authorized party) claim set to your frontend's origin. If you skip validating it, you're accepting tokens issued for any Clerk application. I skipped it in my first pass. Don't do that. The value you check against should be your frontend origin — https://app.yourdomain.com, no trailing slash.

JWKS caching is not optional. The first version I shipped hit the JWKS endpoint on every request. That's fine until your API gets real traffic, and then you're making dozens of outbound HTTP calls per second for no reason. Ten minutes is a safe default. Clerk rotates keys infrequently and includes multiple active keys in the set precisely so you can cache aggressively. If you do get a key-not-found error after caching, the right move is to flush and re-fetch once, not to disable the cache.

The token lifetime is short by design. Clerk session tokens expire in 60 seconds by default. Your frontend is expected to refresh them before expiry — that's Clerk's model. If your mobile client or a background job is holding a token and making infrequent requests, you will hit 401s. The fix is on the client side (use getToken() from the Clerk SDK before every API call), not the server side. I've seen developers reach for longer-lived JWT templates in the Clerk dashboard, which is fine, but understand you're trading security for convenience.

firebase/php-jwt version matters. The JWK::parseKeySet() signature changed between v5 and v6. If you're on an older Laravel project that pinned v5, the code above won't work directly. In v5, JWK::parseKeySet() returns a different structure and you need to pass the result differently to JWT::decode(). Just update to v6. The breaking change is minor and worth it.

Clock skew. firebase/php-jwt applies a 60-second leeway for nbf and exp by default (JWT::$leeway). That's usually fine. On a server with a badly drifted clock I saw tokens fail before the leeway kicked in. Sync your server's clock with NTP and stop fighting it.

Organizations aren't in the default token. If you're using Clerk's org feature — and for the real estate portal project, I was, because the client had team accounts — the org_id and org_role claims are not in the session token by default. You have to enable them in a Clerk JWT template or use the Clerk Backend API to fetch org membership separately. I didn't catch this until a client reported that their team members couldn't access org-gated resources. The fix is a custom JWT template in the Clerk dashboard that includes org_id, org_role, and org_slug in the claims. Once you do that, they show up in the decoded payload and you can use them like any other claim.

When I'd Reach for This

This setup makes sense when your frontend team is already using Clerk and you're building a separate Laravel API backend — decoupled architecture, different deploy pipelines, that kind of thing. I see this most in projects where the frontend is Next.js or a mobile app and the business logic lives in PHP.

I wouldn't bother with Clerk at all if both the frontend and backend are Laravel. In that case, Jetstream with Sanctum or a Breeze stack gives you everything Clerk does with no third-party dependency and full control. Clerk earns its place when you need the hosted UI, the social provider management, or the org/team features and you don't want to build any of that yourself.

I also wouldn't use this if your client is in healthcare with strict data residency requirements. Clerk is a US-based SaaS. HIPAA-covered apps I run use self-hosted auth — usually a combination of Sanctum and carefully scoped OAuth — because the compliance story is cleaner. Clerk does offer a BAA, so it's not a hard no, but it's a conversation you have to have before you commit.

The Bottom Line

Clerk's lack of a Laravel SDK sounds like a gap until you realize validating a JWT against a JWKS endpoint is fifty lines of code, not a missing library. The rough edges are in the claims — specifically azp and the organization fields — not the cryptography. Get those right and it's solid. I've had the real estate portal running this pattern in production for over a year without touching the auth layer once.

Need help shipping something like this? Get in touch.