In the phantom-token pattern, why does only the edge trade the opaque token for an inward JWT, and how long should that JWT live?
answer
- opaque outside, self-contained inside
- one trust boundary, one trade
- resolve the reference once at the edge
- inward token lives one request
- its lifetime is the revocation lag
basics
~20 sThe edge is the one place an outside-facing opaque token arrives and can be resolved with a single issuer call, so the trade pays there and nowhere else. The inward JWT should live roughly one request, not one session.
solid answer
~50 sIn the phantom-token pattern the client holds an **opaque** access token — a reference with no readable content — while the services behind the trust boundary want a **self-contained** token they can verify offline. The API gateway at the edge trades one for the other. The edge is where that trade pays: it is the single point every external request already crosses, so one resolution serves the whole chain, and the opaque value stops travelling inward instead of appearing in five services' logs. Resolving deeper is possible but puts an issuer round trip on every hop for no extra safety. The inward token is minted for the life of one request — tens of seconds, not hours — and never goes back to the client. That lifetime is a revocation-lag budget: once minted, revoking the donor's grant at the issuer does not reach it, and it stays spendable until its `exp`.
code
http · 15 linesPOST /donations HTTP/1.1
Host: api.charity.example
Authorization: Bearer 7c1f0a9e-3b2d-4a55-9f60-1e8d2c4b7a03
Content-Type: application/json
{"campaign": "winter-appeal", "amountMinor": 2500}
--- what the gateway forwards inward ---
POST /donations HTTP/1.1
Host: intake.internal
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImVkZ2UtMSJ9.eyJpc3MiOiJodHRwczovL2lzc3Vlci5pbnRlcm5hbC8iLCJzdWIiOiJkb25vci04ZjIxIiwiYXVkIjoiaW50YWtlIiwiZXhwIjoxNzU4MjkwNDYwfQ.ZHVtbXk
Content-Type: application/json
{"campaign": "winter-appeal", "amountMinor": 2500}go deeper
Recall that two different credentials are in play: a reference the client holds that means nothing on its own, and a signed token the internal services read. They are not one token in two formats.
Explain the mechanics: the gateway resolves the reference once and obtains a signed token for the hops behind it, so internal services verify offline. Say why doing that at every hop costs an issuer call per hop and gains nothing.
Show the operational judgment: state the inward lifetime as a number, justify it against the chain's worst-case duration and clock skew, and own it out loud as your revocation lag inside the boundary rather than claiming revocation is immediate.
Frame the trade-off: you are buying offline verification at every internal hop and paying for it with a revocation window you can never close below one token lifetime. Say what would make you widen or narrow that window.
## Two tokens that are not the same credential A phantom-token deployment issues the outside world an **opaque** access token: a high-entropy reference with no readable content, meaningless to anyone who cannot ask the issuer what it stands for. Behind the trust boundary the services doing the work want the opposite — a **self-contained** token they can verify with a signature check and read claims from without a network call. The pattern reconciles the two by putting a single trade at the API gateway: the opaque token comes in, a short-lived self-contained token goes on to the services behind it. Calling these "the same token in two formats" is the mistake to avoid. They differ in who can read them, how fast they can be killed, and how long they are meant to live. | | opaque token (outward) | self-contained token (inward) | |---|---|---| | readable by its holder | no | yes | | verified by | asking the issuer | signature check, offline | | killed by | the issuer ceasing to honour it, immediately | nothing; it runs to `exp` | | intended lifetime | minutes to hours | one request | | crosses the boundary outward | yes | never | ## Why the trade pays at the edge and nowhere else A service three hops in *could* resolve the reference itself. The argument is not that it is impossible, it is that it costs and buys nothing: 1. **One resolution serves the whole chain.** The edge is the single point every external request already passes through. Resolve there and one issuer call covers a donation that will touch intake, the ledger, reconciliation and statement generation. Push the resolution deeper and each hop pays its own round trip, so issuer load becomes the request rate multiplied by the chain depth. 2. **The opaque value stops travelling.** If reconciliation had to resolve the reference, the reference would have to be carried as far as reconciliation. A credential that reaches five services has five sets of logs, traces and error reports to leak from. Trading at the edge means exactly one component ever holds the client's own credential. 3. **The request fails before any work starts.** An unresolvable or expired reference is refused at the boundary rather than three services into a donation that has already written a provisional ledger entry. ## Sizing the inward token The inward token is minted for **one request**, not one session. Size it against the chain's worst-case duration plus the clock skew you allow between your own services: for a synchronous chain that normally finishes inside a second, thirty to a hundred and twenty seconds is already generous. It never goes back to the client, so nothing outside the boundary needs it to survive longer. That number is not a comfort setting. It is your **revocation-lag budget inside the boundary**. Once the gateway has obtained a self-contained token, revoking the donor's grant at the issuer does not reach it, because no hop behind the edge asks the issuer anything — which is the whole reason the inward token is self-contained. If a fraud check cuts a donor off while a donation is mid-chain, the in-flight work keeps its authority until the token's `exp`. If the inward token lives 120 seconds, the honest answer to "how fast can you cut someone off inside the system" is "up to 120 seconds", and that is the number to state instead of claiming revocation is immediate everywhere. ## In the donation chain A donor's opaque token arrives at the gateway. The gateway trades it for an inward token carrying the donor's `sub` and an `aud` the services behind it accept, then forwards the call. Intake records the intent, the ledger posts the entry, reconciliation matches it against the payment processor's settlement report, and statement generation writes the donor's receipt. The fourth service in that chain still has to know on whose behalf it is acting, and the inward token is what tells it. The processor's own callback arrives on a different path with its own credential — it is not the donor, and carrying it as if it were is a different bug entirely. ## What the pattern does not buy you - **It is not confidentiality.** An inward token is signed, not secret. Whatever a service can read out of it, anything that captures it inside the boundary can read too. - **It is not authorization.** The inward token says *who* the request is for. Whether that donor may adjust a posted ledger entry is a separate decision owned by whatever component holds that rule. - **It does not excuse the callee from checking `aud`.** If every service behind the edge accepts any well-signed token, the narrowing the trade makes possible is decoration. - **It does not make the opaque token harmless.** Opacity is not secrecy: the reference is still honoured on possession, and whoever holds it can spend it until the issuer stops honouring it.
- Why not issue the client the short-lived self-contained token directly and skip the trade?Because the client keeps calling, so its credential has to outlive one request — and a self-contained token that lives that long cannot be withdrawn before its `exp`. The opaque reference keeps that power at the issuer: stop honouring it and the next call fails immediately. The trade lets you have instant withdrawal outside and offline verification inside.
- What stops a service behind the edge from accepting an inward token minted for a different destination?Nothing, unless the callee checks `aud` against its own name and refuses anything else. The gateway obtains the token; the callee verifies it — two different components, and only the second one can enforce the narrowing. An `aud` that no callee checks is a claim, not a control.
- The inward token expires mid-request because one hop was unusually slow. What is the fix?The hop refuses the call as expired, and the request fails part-way through. Size the lifetime against the chain's worst case plus your clock-skew allowance rather than its median, and mint fresh per inbound request instead of reusing one across requests. If the worst case is genuinely minutes, that is a signal about the chain, not about the lifetime.
A cloakroom ticket traded at the door for a wristband: the ticket means nothing without the desk that issued it, the wristband is readable by every bartender inside and is good for tonight only. Nobody at the door can take a wristband back off a guest already inside, which is exactly why it is printed to expire soon.
saying these in an interview costs you the question
- Says an opaque token is safer because it cannot be read
- Thinks every service behind the edge should resolve the opaque reference itself
- Gives the inward token a session-length lifetime because it stays internal
- Assumes a gateway-set user identifier can stand in for the inward token
- Believes revoking the donor's grant kills an inward token already minted
- Returns the inward self-contained token to the client as well