Why Inter-Service Auth Needs Client Identity

Update: Not all posts are winners. This got some really good feedback. See the addendum.

There's a gap in how ATProtocol handles inter-service authentication, and it's one that becomes increasingly important as the network grows: when an AppView makes a request to another AppView on behalf of a user, the receiving service has no way to identify which client is making that request.

Let me explain why this matters.

The Current State

Right now, inter-service authentication lets AppViewA call AppViewB on behalf of a user. The JWT proves that the user authorized the action, but it doesn't identify AppViewA as the caller. From AppViewB's perspective, it's just "some authorized client acting for this user."

For a small network, this works fine. But as the ecosystem matures, we're going to need more nuance.

Why Client Identity Matters

AppView access controls. With inter-service auth, a request can come in for an identity that a service has no prior relationship with, but the client making the request is a known bad actor on the network. Service owners need the ability to block AppViews that are directly or indirectly causing harm—whether to the service itself, its users, or the broader network.

Consider a malicious AppView designed to organize brigades and harassment campaigns. Beyond scraping public network data, it makes inter-service calls to a feed generator to identify top posters on LGBTQ-focused feeds, then targets them for harassment. If the feed service can see the client_id in the request, it can block that AppView entirely—even when the request comes with a valid user token.

User data boundaries. AppViews are increasingly becoming sources of protocol-adjacent information that doesn't live in a user's repo. Users may want to share certain data with other users while maintaining control over which services can access it.

Imagine a location service that provides supplemental information for events and posts—sometimes semi-private or sensitive. A user enters a street address for a party through a calendar AppView and wants only accepted attendees to see it. But they go a step further: they only trust that specific calendar AppView to properly implement caching and access controls. They add a rule that denies other AppViews access to their sensitive locations, even if those AppViews are making requests on behalf of valid, authorized identities. Without client_id in the inter-service JWT, this kind of granular control isn't possible.

Business relationships between services. Imagine AppViewA and AppViewB have struck a deal: maybe higher rate limits, access to beta features, or special integrations. Without client identification in the JWT, AppViewB has no reliable way to recognize AppViewA and apply those terms. You'd have to fall back to IP allowlists or API keys layered on top of the existing auth—ugly workarounds that defeat the elegance of the inter-service auth system.

Why not just use headers? You could argue that clients should just self-identify with a custom header. But why introduce values that can be trivially forged? Anyone can slap an X-Client-Id: TrustedApp header on a request. We already have the infrastructure for cryptographically signed authorization—that's the whole point of JWTs. The client identity should live there, where it can be trusted.

Why OAuth Scopes Aren't Enough

You might think this is already solved by OAuth scopes. After all, users authorize specific scopes when they connect an app, so can't they just limit what services can do on their behalf?

The problem is that scopes define what actions are permitted, but they don't address who gets to take those actions. A receiving service has no way to distinguish between two different clients making the same authorized request. Both might have valid tokens with the correct scopes, but the receiving service might have very different relationships with each of them.

Client identity in inter-service JWTs gives us a foundation to build service-to-service trust without requiring additional authentication mechanisms bolted on top.

The Fix

The solution is straightforward: include the client ID in inter-service JWTs. The receiving service can then make decisions based on who's calling, not just who the user is.

Here's what an inter-service JWT payload might look like with client identity included:

{
"iss": "did:plc:abc123xyz",
"aud": "did:web:calendar.example.com#calendar",
"lxm": "community.lexicon.calendar.getEvents",
"client_id": "https://smokesignal.events/oauth-client-metadata.json",
"iat": 1733011200,
"exp": 1733011260
}

The client_id claim identifies the calling service, so in this case Smoke Signal is requesting calendar events on behalf of a user. The receiving calendar service can now make informed decisions: apply rate limits, check for business agreements, or respect user preferences about which clients can access their data through inter-service calls.

A note on compatibility: the client_id field must be optional. Not all inter-service authentication originates from OAuth sessions. When a user authenticates via an app password or their real password, there's no originating client to identify because the user is effectively acting directly. In these cases, the client_id claim would simply be omitted. This keeps the change backwards compatible and avoids breaking existing authentication flows that don't have client context to pass along.

This opens up a whole category of features around service-to-service trust, rate limiting, feature flags, and AppView boundaries. It's the kind of foundational change that makes the network more flexible without adding complexity for services that don't need it, they can simply ignore the client ID if it's not relevant to them.

Update!

After a really good discussion with @thisismissem.social, there's a gap and potential for abuse that is worth mentioning. Were this idea to ever evolve into an implementation, it would be important to consider.