DEV Community

Cover image for Service-to-service auth: clients, audiences, and scopes
Anthony
Anthony

Posted on

Service-to-service auth: clients, audiences, and scopes

Imagine a perfectly valid token arriving at the wrong service. Its signature checks out. It has not expired. Yet accepting it would still be a mistake: it was issued for a different API. This is the part of service-to-service auth that is easy to miss when the conversation starts and ends with “we use JWTs.”

When one internal service calls another, “they are both inside our network” is a weak authorization rule. The useful question is specific: may orders-service create an invoice in billing-api?

I use four names to answer it:

Name In this example What it means
Client orders-service The service making the call
Audience billing-api The API meant to receive the token
Scope billing.invoice.create The action the client may request
Issuer Orionis The service that authenticates the client and signs the token

Those four values carry different responsibilities. Keeping them separate is the first useful step toward a service permission model that can be explained and reviewed.

If you remember only one line from this article, make it this: a client is who asks, an audience is who receives, and a scope is what the receiver may let the caller do. The issuer is the party whose statement the receiver is willing to verify.

Keep the trust boundary visible

There are three different secrets or keys in this picture. The orders service holds its own client credential so it can authenticate to Orionis. Orionis holds the private signing key. Billing needs neither of those: it obtains public verification keys through JWKS and receives a bearer token from orders.

That separation helps when you add a second caller. If reporting-service may read invoices but may not create them, give it its own client identity and allow only billing.invoice.read. Do not share the orders credential with it. A leaked reporting credential then does not automatically grant invoice creation.

It also helps when billing calls another API. On the incoming request billing is a resource service. On its outgoing request it becomes a client. The roles describe an interaction, not a permanent label attached to a process.

Follow one request

In my Orionis example, the orders service needs to create an invoice. The flow is:

orders-service -- client credentials --> Orionis
orders-service <-- signed access token -- Orionis
orders-service -- bearer token -------> billing-api
billing-api    -- validate + authorize -> invoice handler
Enter fullscreen mode Exit fullscreen mode

Orionis checks the client's credentials, requested audience, and requested scope before issuin a short-lived token. The orders service sends that token to billing. Billig checks it before running the handler.

The audience matters even when the same caller can reach several APIs. A token issued for billing-api should not be accepted by dispatch-api. The scope narrows the permission further: reading an invoice and creating one should be distinct actions.

A client is not a user

The OAuth 2.0 client_credentials flow represents the calling application. There is no end-user login in this exchange.

In Orionis, the token carries the service identity as sub and client_id. It also carries the audience, scope, issuer, expiry, and a token_use value for access tokens. Those claims let the receiving service answer two questions:

  1. Was this token issued for my API by an issuer I trust?
  2. Does this service have permission for this route?

If an endpoint also acts on behalf of a person, that person's authorization needs its own design. A service scope should not silently stand in for a user's permission.

What the access token says

A signed JWT is a compact way to carry the result of the token request. In the Orionis example, these are the fields I care about at the receiving API:

Claim Why billing checks it
iss The token came from the expected issuer
aud The token is intended for billing-api
exp and nbf It is within its valid time window
sub and client_id The caller has a service identity
scope The action is among the issued permissions
token_use This is an access token

The signature protects those claims from bing changed after issuance. It does not make every token acceptable to every service. Billing still compares the claims with its own expected issuer, audience, and route permission. Orionis also includes iat and a unique jti, but neither replaces the audience or scope check.

A token is a bearer credential. Anyone who obtains it can try to use it until it expires, so the caller should avoid logging the token or forwarding it to unrelated services.

Put the permission where the action happens

The billing route in the runnable Go example uses a route-level requirement:

r.POST(
    "/invoices",
    guard.Require("billing.invoice.create"),
    handler,
)
Enter fullscreen mode Exit fullscreen mode

This is a useful place for the rule. A reader of the route can see exactly what permission it requires. The handler receives verified claims only after the token passes the guard.

I keep scope names concrete: billing.invoice.create, billing.invoice.read, and so on. Orionis can accept wildcard selectors when a trusted client requests scopes, but the issued token contains concrete scopes. The receiving route still checks a concrete action.

Choose scopes from API actions

I start with the receiving API's operations, not with organizational roles. For billing, GET /invoices/:id could require billing.invoice.read, while POST /invoices requires billing.invoice.create. Then I decide which callers need each action. This keeps the authorization rule near the handler that performs the work.

The token endpoint can grant only scopes listed for the authenticated client. If a client asks for a scope outside that list, Orionis rejects the token request. If it omits scope, Orionis uses the client's configured defult scopes. Defaults are useful for simple callers, but I still keep them narrow.

There is one subtlety with wildcard scope requests. Orionis can expand a trusted client's selector, such as billing.invoice.*, into matching concrete allowed scopes. The token contains those concrete names, so the receiving route remains explicit. I would avoid handing a wildcard request to a client unless it genuinely needs every matching action.

Three mistakes this model helps expose

Treating the target service as the client. Billing is the audience when orders calls it. Billing needs a client identity only if billing itself calls another service.

Using one broad scope everywhere. A scope such as billing.all makes review difficult. A small permission vocabulary tied to API actions is easier to reason about.

Checking only the signature. A valid signature says who issued a token. The resource service must also check issuer, audience, expiry, token use, and the route's required scope.

A small design exercise

Suppose the same orders-service must also read a delivery status from dispatch-api. I would make a second token request for audience dispatch-api and scope dispatch.status.read. I would not forward the billing token to disptch: it names the wrong recipient, even if both APIs trust Orionis. The caller can cache the two tokens separately.

Now suppose billing-api needs to ask tax-api for a rate. Billing is no longer only a receiver. For that outgoing call it needs its own registered client credential, an allowed tax-api audience, and a scope such as tax.rate.read. Giving billing a client identity does not make it an audience for the orders call; these are independent relationships.

That exercise is useful in a review. Draw an arrow for each call, then write the client, audience, and route scope beside it. If an arrow has no named recipient or permission, the authorization design still has a gap.

The practical takeaway is simple: name the caller, the receiving API, and the permitted action separately. Once those are explicit, the code and the review conversation become much clearr.

The Orionis architecture guide shows the complete flow. The next article turns it into an HTTP call between two Go services

Top comments (0)