NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
JWT for Laravel

Service tokens

Service tokens are short-lived HS256 JWTs one back-end service presents to another. Each is pinned to scope = service and locked to a named sender (iss) and recipient (aud):

use RoundlyConsulting\Jwt\Facades\Jwt;

$token = Jwt::services()->issue('target-service')->token;

// Or attach one to an outbound internal HTTP call:
Jwt::services()->request('target-service')
    ->post('https://target.internal/endpoint', [...]);

// On the receiving side, behind `auth:service` (a `service-jwt` guard):
Jwt::services()->claims()?->string('iss');   // the calling service

An issued token carries iss (service.issuer), aud (the argument, else service.audience), iat, nbf, exp, jti and scope = service, and lives service.ttl seconds — 60 by default. Issuing with no resolvable audience or an empty own issuer raises ServiceAuthMisconfigured: an unaddressable or anonymous token is never minted.

Extra claims

issue() takes an optional array of extra claims — for example context for a worker acting on the holder’s behalf. Registered names are rejected rather than silently overwritten:

// Extra, non-registered claims travel with the token:
Jwt::services()->issue('notifications', ['job' => 'nightly-sync']);

// A registered name (iss, aud, iat, nbf, exp, jti, scope, sub,
// permissions, tv, email, email_verified) is rejected, not merged:
Jwt::services()->issue('notifications', ['aud' => 'billing']);   // throws ServiceAuthMisconfigured

Outbound calls

Jwt::services()->request() and authenticate() attach a freshly issued token as a bearer credential to Laravel’s HTTP client:

use Illuminate\Support\Facades\Http;
use RoundlyConsulting\Jwt\Facades\Jwt;

// A new pending request already carrying a fresh token for the audience.
$response = Jwt::services()->request('notifications')
    ->post('https://notifications.internal/send', $payload);

// Or attach a token to an existing pending request.
$pending = Http::baseUrl('https://notifications.internal')->acceptJson();
$response = Jwt::services()->authenticate($pending, 'notifications')->post('/send', $payload);
  • request(?string $audience = null) — a new PendingRequest already carrying a fresh token.
  • authenticate(PendingRequest $request, ?string $audience = null) — attach a fresh token to an existing pending request.

Choosing a secret mode

With one shared secret, possession of the secret is the only proof — any holder can mint a token claiming any iss, so the issuer allow-list is a label, not authentication:

# Shared mode: one secret for the whole mesh (openssl rand -base64 48).
SERVICE_JWT_SECRET=<secret>

For real service identity, give each service its own secret. The verifier picks the secret by the token’s iss — a kid-style lookup — so a forged iss selects a secret its signature cannot match, and one leaked secret no longer impersonates the whole mesh:

# Per-issuer mode (recommended): overrides SERVICE_JWT_SECRET when set.
SERVICE_JWT_SECRETS="billing:<secret>,api:<secret>"

In per-issuer mode the issuing app’s own service.issuer must appear in the map, or issuing raises ServiceAuthMisconfigured. An unknown inbound issuer is checked against a fixed dummy secret and rejected as InvalidSignature — the same work and error as a bad signature, so configured issuer names can’t be enumerated.

Receiving service tokens

Inbound tokens must be addressed to this service’s own name — jwt.service.name, set with JWT_SERVICE_NAME. Unset, it falls back to config('app.service'), then to a slug of APP_NAME; set it explicitly to a stable identifier, because renaming the app would otherwise re-pin the service and break every caller:

# This service's own name — the aud every inbound service token must carry.
JWT_SERVICE_NAME=notifications

# Optional inbound allow-list; empty accepts any issuer.
JWT_SERVICE_ISSUERS=billing,api

Verification requires a valid HS256 signature, scope = service, aud equal to the service name, a non-empty iss and — when service.issuers is set — an iss on that allow-list. Wire the guard and read the calling service:

// config/auth.php
'guards' => [
    'service' => ['driver' => 'service-jwt'],
],

// routes
Route::middleware('auth:service')->post('/send', SendController::class);

// inside the controller
$caller = $request->user('service');  // ServiceIdentity
$caller->getAuthIdentifier();         // the calling service's 'iss'
$caller->claims();                    // the full Claims bag

Jwt::services()->claims();            // the same claims, through the facade
auth()->guard('service')->payload();  // …or straight from the guard

Or verify explicitly:

$claims = Jwt::services()->verify($jwt);

A missing, too-short, single-repeated-byte or PEM-shaped secret, or no resolvable service name, raises ServiceAuthMisconfigured — a 500, never a silent 401.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.