Authenticating a caller that isn't a person — protecting service-to-service payment calls without handing out clues
Introduction
In the homemade banking-integration system I’m building, a payment spans multiple services. The payment service calls the account service’s internal API to move money. Here, the one making the call isn’t a person — it’s the service itself.
For a human login, you confirm identity with an ID and password or a passkey. But between services, the other party you’re confirming isn’t a person. Carrying over the assumptions of human login as-is here opens a hole that hands attackers clues. This article is about the three design choices I put in place to close that hole.
Note that this is about authentication — confirming who the caller is — which is a different layer from authorization, whether that caller can only touch their own account. The pitfall I stepped into on the authorization side (a flaw that let someone operate another person’s account) is written up in The identity check that GET protected, POST dropped entirely — the IDOR I nearly built into the account API.
How do you authenticate a caller that isn’t a person?
For the payment service to call the account service’s internal API, it needs not a human login token but the service’s own token. Here’s how it works. The payment service identifies itself to the auth service with an ID and secret key shared in advance, and receives back a single short-lived token. It attaches that token when it hits the account service’s internal API.
This approach — “a program, not a person, authenticates with a pre-shared ID and secret” — is called client_credentials (client authentication). The token it receives carries only a mark saying “this call comes from an internal service” (role: service), and the account service sees that mark and permits use of the internal API.
Payment service ──① identifies with ID+secret──▶ Auth service
Payment service ◀─② short-lived token(role:service)─┘
Payment service ──③ attaches the token──▶ Account service's internal API (requires role:service)
It looks straightforward. The problem lies in how this entry point — “identify yourself, receive a token” — is built. Build it with the same mindset as a human login, and three holes open up.
First: make the token short-lived
First, I cut the lifetime of the issued token to 5 minutes.
For human login, tokens often live tens of minutes to hours, for convenience. But carry that mindset into service-to-service tokens, and a token that leaks even once keeps working for a long time. If a single token leaks through a gap in logs or traffic, the attacker can keep hitting the internal API for the whole time it’s valid.
If it’s short-lived, even a leak has a short window of damage. The payment service holds the token in hand matching its lifetime, and re-fetches it before it expires. Not “long because it’s convenient” but “short because a leak would be a problem” — the opposite weighting from a human login.
Second: don’t distinguish between “doesn’t exist” and “is wrong”
The second point is what I most want to convey in this article. The authentication entry point must never hand an attacker any clue whatsoever.
Build it naively, and if the ID presented is unregistered you return “that ID doesn’t exist,” while if it’s registered but the secret is wrong you return “the secret is wrong” — two different responses. It seems considerate, but this tells an attacker which IDs actually exist. Once they know a real ID exists, all that’s left is to brute-force the secret. The difference in response itself becomes a clue (an oracle).
So, an unregistered ID and a mismatched secret are returned as exactly the same failure. The attacker can’t even tell whether the ID they entered exists.
【Naive】 ID unregistered → "ID doesn't exist" ┐ responses differ
Secret mismatch → "Secret is wrong" ┘ → leaks which IDs exist (a foothold for brute-forcing)
【Fixed】 ID unregistered → same failure ┐
Secret mismatch → same failure ┘ → can't even tell whether the ID exists (zero clues)
There’s one more layer. If you compare the secret key with an ordinary string comparison (matching character by character from the start, stopping the instant one differs), the comparison time grows with the number of matching characters. Measure that time difference, and you can pull the secret out one character at a time (a timing attack). So the comparison must always use a method that runs in the same time no matter what — a timing-safe comparison.
// Return the same failure whether the ID is unregistered or the secret mismatches (hides which IDs exist)
var matched = clients.stream()
.filter(c -> c.clientId().equals(clientId))
.findFirst()
.orElseThrow(InvalidInternalServiceClientException::new);
// Secret comparison is timing-safe (prevents guessing the secret from comparison time)
boolean ok = MessageDigest.isEqual(
matched.clientSecret().getBytes(UTF_8),
clientSecret.getBytes(UTF_8));
if (!ok) throw new InvalidInternalServiceClientException(); // same exception as above
Kindness and safety point in opposite directions at the authentication gate. An error that’s easy for a legitimate user to understand is just as easy for an attacker to understand.
Third: don’t mix internal and external “ledgers”
The third point is separating the trust boundaries of the parties a token is issued to.
This system has two kinds of recipients for tokens. One is an internal, sibling service (like the payment service, one of the family inside banklink). The other is an external merchant (an outside business that uses the payment feature). These two must not be handled through the same issuance point and the same registry.
So I split the registry (ledger) itself. The internal-service registry and the external-merchant registry are kept as separate things. The nature of the issued tokens is also separated — internal-bound tokens carry only a mark saying “this is an internal service” (role: service), while external-merchant-bound tokens carry a different mark saying “this is a token for merchant payment, addressed to this customer” (purpose: merchant_payment plus the target customer). Even though it’s the same “token issuance,” I deliberately kept the registry and the class separate for what’s issued to family versus what’s issued externally.
┌─ Internal-service ledger ──▶ role:service (can call internal APIs)
Token issuance ─┤
└─ External-merchant ledger ─▶ purpose:merchant_payment (payment addressed to this customer only)
▲
Merging these two into one registry / one issuance point
risks handing an internally-privileged token to an external party (boundary collapse)
If the two were merged into a single issuance point, a small mix-up in the code could easily hand an external merchant a token carrying the strong privileges meant for internal services. A boundary is protected by physically separating it into a shape that can’t be confused. Merging them because splitting is a hassle isn’t an option here.
At the authentication gate, choose “give no clues” over kindness
The three designs shared one root: don’t carry the assumptions of human login over to services as-is.
- Tokens are made short not for convenience but for when they leak.
- Errors are made indistinguishable not for clarity but to give no clues.
- Issuance points are split by trust boundary, sparing no effort.
Every one of these designs was chosen by first giving up “kindness to the user.” For a screen aimed at people, a clear error and a long session are both correct. But when the other party at the authentication gate isn’t necessarily a person, kindness becomes a clue for the attacker as-is. Assuming the other side isn’t a person and reversing the weighting of convenience — that’s what sank in for me with service-to-service authentication.
Related articles
- The pitfall on the flip side — “who is calling” (authentication) versus “can this caller only touch their own resources” (authorization) — is written up in The identity check that GET protected, POST dropped entirely — the IDOR I nearly built into the account API.
- Where this service-to-service token is actually used — the consistency story of the payment service crossing into the account service to complete a transfer — is covered in Keeping money from vanishing when a service dies mid-transfer — protecting consistency across accounts with Saga and Outbox.