---
title: "Service tokens — JWT for Laravel | Roundly"
description: "Short-lived HS256 machine-to-machine tokens: issue them, attach them to outbound HTTP calls, pick a secret mode and verify inbound calls."
url: https://roundly-consulting.com/open-source/docs/jwt-for-laravel/service-tokens
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[JWT for Laravel](https://roundly-consulting.com/open-source/docs/jwt-for-laravel.md)

# 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):

```php
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:

```php
// 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:

```php
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:

```bash
# 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:

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

Each issuer appears in the map once: a repeated issuer (compared after trimming, case-sensitive, like the token’s iss) throws JwtMisconfigured when the service is resolved, instead of letting the last pair win. The message never prints a secret — it names the issuer only when it looks like one (^\[A-Za-z0-9\]\[A-Za-z0-9.\_-\]\*$ and under 32 bytes), otherwise just the colliding entries’ positions (“entries 2 and 5 name the same issuer”), so a pair written secret:issuer stays hidden. A host whose map repeats an issuer fails at boot until it keeps one entry per issuer.

In per-issuer mode the issuing app’s own service.issuer must appear in the map, or issuing raises ServiceAuthMisconfigured. An inbound issuer absent from the map is always rejected as InvalidSignature, even when service.issuers allow-lists it. Its HMAC check still runs, against a random key drawn per service instance, so it costs the same work and fails with the same error as a bad signature — configured issuer names can’t be enumerated, and no learnable key ever lets an unknown issuer through.

## 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:

```bash
# 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:

```php
// 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:

```php
$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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/jwt-for-laravel.md)
