View source on one of our generated widgets and you can find a tenant id, a connector handle and the address of a token issuer. Take those values to a terminal and you can obtain a valid token. Nobody has stolen a signing key. The issuer is doing what it was built to do.
I run backend engineering at GoodBarber, where we are testing an AI extension builder. Its widgets call third-party APIs through a proxy that holds the real API keys. The feature is a prototype serving a handful of pilot tenants; the design below is still evolving.
The token tells the proxy which tenant the issuer accepted a request for. It does not identify the person making the call. That distinction determines what we can safely authorize, and it leaves a residual risk that a signature cannot remove.
What a bearer token proves
A bearer token grants its permissions to whoever possesses it. Its use does not require a separate proof that the presenter holds a cryptographic key. That is the model described by RFC 6750, section 1.2.
We use signed JWTs. Verifying one against a trusted issuer's key establishes that the signed claims have not been altered. The consumer must also check the claims required by its protocol, including issuer, audience and expiry. JWT itself supports other forms, including encryption, and does not require every application to use the same claims.
For our bearer tokens, verification does not establish who is holding the token now. Nor does a signature make an unverified claim true. If an issuer copies a caller-supplied label into sub without authenticating that caller, the downstream API cannot turn it into an authenticated identity by checking the signature.
What our issuer checks before it signs
Our issuer mints tokens for a tenant id. The endpoint is public. Before it signs, it asks the client to solve a small proof-of-work, which makes minting cost CPU rather than a login. The proof-of-work is a cost, not a gate: a script that is willing to spend the cycles gets a token. The issuer checks that the tenant is a legitimate customer when it creates that tenant's signing key; after that, every request for that tenant is served within the rate limits.
Each tenant has its own RS256 key pair, the public half is published as a JWKS per tenant, and tokens live fifteen minutes. After verification, the proxy has a tenant label backed by our minting process: somebody completed its challenge for that tenant recently enough for the token to remain valid. That somebody may be an untrusted caller.
The question we stopped asking
We first considered a signed context from the native app: something the mobile runtime could attach to a request, independently of the page. Our production SDK does not provide that, and the web version of the widget has no native runtime to ask.
So we designed for the caller who can obtain a valid token without earning our trust. The proxy has to enforce three constraints even for that caller.
- The secret never leaves the server. The third-party key exists in plaintext only in the proxy's memory, for the duration of one outbound request. It is not in the page, not in the bundle, not in the response, not in the logs.
- Egress is bounded by what was provisioned, not by what the caller asks. A connector is registered by an authenticated developer with a host, a base path and a list of verbs. The widget sends a relative path. It never sends a host.
- Abuse is capped. Per-tenant and per-connector rate limits, a kill switch per connector, and a global one.
The order of operations follows from the first invariant. In pseudo-code, one request through the proxy looks like this:
verify token -> 401 if invalid; 503 if verification keys are unavailable
resolve connector -> by (tenant from token, handle from URL), no secret loaded
check kill switch -> 503
check verb allowlist -> 403
check path allowlist -> 403
parse destination -> host must match the provisioned allowlist
resolve DNS and pin IP -> block private ranges, keep Host and SNI for TLS
count against limits -> 429
decrypt the secret -> only now
inject, forward, redact -> scrub the key and the credential forms our injectors construct
Those admission checks run before decryption. On the way back, the response is scrubbed against the key and the credential forms the injectors construct, including a Bearer header or a base64 Basic pair.
A caller with a valid token can still invoke a provisioned connector, within its permitted host, path and verbs, until the quota says stop. The controls are intended to prevent extracting the key or redirecting its use beyond that scope. They do not prevent every harmful use within it. Burning someone else's API quota is one such use.
What the token still buys
The issuer still has useful work to do.
Attribution and cost. Every request carries a tenant id that the caller had to spend something to obtain, and every abuse has a label. Rate limits per tenant only make sense when the tenant claim cannot be free-typed by the caller. The signature is what makes the label trustworthy, even though the label is not an identity.
Browser restrictions. The mint runs inside an iframe served by the issuer. Its allowed parent origins come from our tenant registry, and the proxy applies the same per-tenant list to CORS. An unrelated site cannot simply embed that mint in its own page. A client outside the browser remains a different case.
The native limit. Our iOS plugin pages share one fixed origin across apps. Android's asset-loader origin is shared by other apps using that loader too. Neither origin distinguishes one app installation from another. The token's minting cost and the proxy's quotas still apply, but an origin check adds no installation identity.
A possible extension. DPoP, specified in RFC 9449, can bind an access token to a client-held key and require a proof with each request. Adopting it would require validation of that proof and its binding to the token at the resource server. It would add a possession check; identifying the person behind the key would remain a separate question.
Writing the residual risk down
Before writing the proxy, we put the remaining risk into a paragraph of the security specification and recorded where we accepted it.
The paragraph says, in substance: anyone who can mint a token for a tenant and knows a connector handle, which is public in the bundle, can invoke that connector within its declared scope, burn quota or trigger the side effects the connector allows, without ever extracting the key. It lists the five things that bound that risk: the secret is not exfiltrable, the allowlist is per credential, the limits, the kill switch, and the upstream control over who can publish code into a tenant's app. It says the risk is accepted for read-only connectors and that side-effecting ones need tighter quotas and idempotency.
That paragraph gave reviewers specific questions to investigate: could a redirect expose the secret? An error page? A log line or a cache? Several findings produced fixes. The specification grew to 108 numbered requirements, each phrased so that a test can fail it. The risk paragraph explains why those requirements exist and which harms can remain when they all hold.
What we would change
Three changes remain on our list:
- A dedicated audience for each consumer of the issuer, before a second consumer exists. A token meant for one service should not verify on another.
- Per-tenant quota overrides on the proxy, before opening the feature beyond the current pilot tenants. Aggregate limits per connector are the right posture against abuse and the wrong posture for a popular app.
- A signed install context, the day the native SDK can provide one. The proxy will accept it as an additional check, not as a replacement for the invariants.
On September 16 I verified the same published widget on the web, on an iOS simulator and on an Android emulator: one GET and one POST through the proxy, the response redacted, the key absent from the page. Those checks exercised the request path. The adversarial reviews examined specific ways to escape it. Neither is a proof that no other attack exists.
An API using this design has to be willing to grant the connector's declared capabilities to someone who can obtain the public token. If a capability requires an identified user, that identity needs its own authentication step. Quotas and a signature cannot supply it.
Before trusting a claim downstream, I want to know which check caused the issuer to write it. Which claim in your API would be hardest to trace back to that check?
Top comments (0)