Connections, keys and tokens
Connections#
A connection is a key or token plus the hosts it may be sent to. Apps never see the secret: wirl attaches it on the way out and forwards the request, so the call reaches the vendor with the credential in place while the app itself never holds it. A vendor has 60 seconds to answer, whole, unless it streams: ask a model for a stream. Limits has the rest.
Declare what an app needs in wirl.json:
{
"connections": [
"hubspot",
{ "slug": "stripe", "hosts": ["api.stripe.com"], "kind": "http_bearer" }
]
}
A hosts list can only narrow a connection's own allowed hosts, never widen them. kind says what shape the credential takes, which decides what wirl adds to the outgoing request and what the person is asked for on the keys page:
kind |
What wirl adds to the request | What the person types |
|---|---|---|
http_bearer (the default) |
Authorization: Bearer <token> |
the token |
http_header |
one header, under the name they give | the header's name and its value |
basic |
Authorization: Basic <username:password, base64> |
a username and a password |
none |
nothing: the hosts are the whole connection | nothing |
webhook_secret |
nothing, ever: it checks incoming webhooks and has no hosts | the signing secret |
Pick http_header for a vendor that wants its key in a header of its own. Anthropic is one: the header is x-api-key, and any other header the vendor needs (anthropic-version, a content type) is the app's to send as usual. wirl removes any Authorization header the app sets itself before adding its own. Every other header your app sets reaches the vendor as it set it, User-Agent included, so a vendor that asks for a descriptive one gets yours. The exceptions are the ones wirl owns or that mean something on one hop only: Authorization, Proxy-Authorization, Cookie, Host, Content-Length, any header starting x-wirl-, and Connection, Keep-Alive, Transfer-Encoding, Upgrade, TE and Trailer. wirl also drops the headers the hops in front of it add on the way in, which would tell the vendor wirl's own address. Any starting X-Forwarded- or X-Railway-, and Forwarded, X-Real-IP, True-Client-IP and Via, are dropped whoever set them. So are the ones Cloudflare adds itself: CF-Connecting-IP, CF-Connecting-IPv6, CF-Pseudo-IPv4, CF-Ray, CF-Visitor, CF-Worker, its visitor-location headers CF-IPCountry, CF-IPCity, CF-IPContinent, CF-IPLatitude, CF-IPLongitude, CF-Region, CF-Region-Code, CF-Metro-Code, CF-Postal-Code and CF-Timezone, its bot verdicts CF-Bot-Score, CF-Verified-Bot, CF-JA3-Hash and CF-JA4, its client-certificate headers (any starting CF-Cert-), and Exposed-Credential-Check and Malicious-Uploads-Detection. Any other CF- header your app sets reaches the vendor as it set it, such as AI Gateway's cf-aig-cache-ttl or Cloudflare Access's CF-Access-Client-Id, and so do Cloudflare's loop guards, CDN-Loop and CF-EW-Via, so that Cloudflare can still stop a loop when one wirl app calls another through wirl. A connection's own key header is added after all this, so it is always sent.
Two older kinds, env and postgres, are refused: nothing ever delivered them. wirl never hands a secret to an app, so instead of env, declare the vendor's host and fetch it, and wirl attaches the key on the way out. An app cannot open a database connection, so instead of postgres, use env.DB, or call the database's HTTP API through an ordinary connection.
If the connection does not exist yet, deploying creates it, keyless, and hands back a link to the app's keys page:
wirl deploy
# Deployed crm-sync v1
# Needs a key: hubspot. Add it here, and the app starts working:
# https://app.wirl.dev/apps/acme/crm-sync/connections
The person opens that link and types the key into their own browser session; it goes straight into the vault. Until a key is set, calls on that connection are refused with a message saying the key is missing and pointing at the same link, rather than failing silently. A call to a host with no declared connection at all is refused outright.
Every deploy and preview says, for each connection wirl.json declares, what it can do now, on one of three lines: Connections usable now: names those the app is granted that have their key or need none; Waiting on an admin: names those it is not granted yet, which wait until whoever made the connection, or an org admin, approves them (a deploy token never grants one, so a deploy made with one leaves every connection the app is not yet granted waiting); and Needs a key: names those granted with no key yet, with the link above.
A slug the org already has is that connection, not a new one. The deploy binds the app to it and says so on a line of its own, such as httpbin: the org's existing connection (http_bearer, holds a key), made by Ann Lee <ann@acme.test>; also granted to reply-drafter. The maker's name and address are there only when you are a member of the app's org or made the connection yourself: anyone else, such as someone with a share on the app and no seat in the org, reads made by someone else, and in --json a name and an email of null. The maker's user id, createdBy in --json, is null for them too. The apps it names are the others granted that connection that you can open, and a key added for it is sent for each of them, unless its grant records another kind. In --json, connections.resolved says the same of each declaration.
Each deploy that goes live records on the app's grant the kind it declared for each connection, or, with no kind declared, the kind the connection had then, in the same step that puts it live, so a deploy refused before that records nothing on a grant its app already had. The hosts it declared for each connection are recorded in that same step, or, with no hosts declared, every host the connection allows, so neither a preview nor a deploy refused before it goes live changes the hosts on a grant its app already had. A preview shares the app's grant and records nothing on one the app already has, neither a kind nor hosts, and a rollback changes nothing recorded: the grant keeps the kind and the hosts on record, whichever version is live. A preview's own calls through a connection its wirl.json declares may reach the hosts declared there, or, with none declared, every host the connection allows, whatever hosts the grant has on record. A grant a deploy or a preview opens, for a connection its app had no grant of, granted, waiting or revoked, records the kind declared for it, or, with no kind declared, the kind the connection had when it was checked, and keeps it if the deploy is refused; should another deploy change that kind, or the connection be deleted, after it was checked and before the grant is opened, the deploy or preview is refused, saying so: after a change it asks to be run again, and after a delete to use another slug, since a deleted connection's name stays taken. Such a grant records no hosts: until a deploy of the app declaring the connection goes live, which records the hosts it declared, a call through it reaches no host, except a preview's own call, which reaches the hosts the preview declared. Moving an app to another organisation and back drops its grants, so a grant a preview or a deploy opens for it afterwards, even once approved, lets its live version reach no host until a deploy of the app declaring the connection goes live. wirl attaches a key only while the connection is of the kind recorded: a call on a connection of another kind is refused as kind_mismatch, with nothing sent. A grant with no kind on record is used as it always was: one made before wirl recorded kinds, one opened by wirl connections request or the request API, one made from the dashboard's add-a-connection form, and one a preview or a refused deploy opened before wirl recorded kinds on those too. It gets a kind when a deploy of its app goes live declaring the connection, or the old kind when another deploy changes the connection's kind, so approving it later sends nothing until its app declares the new one. A waiting or revoked one also gets the kind declared when a deploy of its app that would change the connection's kind asks for it again, as below. So a deploy refuses a declared kind the connection does not have: declare the connection's kind, or use another slug. The one exception changes the connection instead: when a deploy, never a preview, by its maker or an org admin declares another kind for one with no key yet, no other app is granted it, and neither kind is webhook_secret, the connection takes the kind declared as the deploy goes live, and the deploy says so on a Changed: line; while another app is granted it, the deploy is refused, naming the other apps you can open. A deploy that would make that change but is refused before it goes live leaves the grant it opened recording the kind it declared, so a call that reaches that grant is refused as kind_mismatch, with nothing sent, until a deploy of the app declaring the connection goes live, or another deploy changes the connection to the kind it declared. The same holds for a waiting or revoked grant of the app with no kind on record that such a deploy asks for again: it records the kind the deploy declared. A grant such a deploy opens, unlike one it asks for again, has no hosts until a deploy of the app declaring the connection goes live, as above, so until then the only calls that reach it are those that name the connection and those of a preview that declares it, and after such a change only that preview's calls reach a host through it: every other call is refused, with nothing sent.
A host that needs no key (a public API, a company service that checks nothing) is declared with "kind": "none" and its hosts; nothing is ever asked for, and the request goes out as the app sent it, to those hosts only:
{ "connections": [{ "slug": "openweather", "hosts": ["api.openweathermap.org"], "kind": "none" }] }
Locally under wirl dev the same rules apply to every call that goes through wirl, whether or not the app declares any connection: a call to an undeclared host is refused, and so is a host that resolves to a private address, unless .wirl/mocks.json sends its calls to a server of your own (see "Trying a connection without its key", below). Keys come from .wirl/keys.json there, and nobody has to approve anything, so a connection still waiting on an admin works locally and is refused deployed until it is approved.
Vendor SDKs, and wirl's refusals#
A vendor's own SDK that sends its key in a header works through a declared connection, because wirl takes its calls on the way out like any other fetch. Give the SDK any placeholder as its key (new Stripe("placeholder")), since wirl puts the real one in on the way out, by the connection's kind:
http_bearerandbasic: wirl removes theAuthorizationheader the SDK sends and sets its own.http_header: wirl sets each header the connection names, replacing the SDK's header of that name, so the placeholder an Anthropic SDK sends inx-api-keynever reaches Anthropic. A placeholder the SDK sends in any other header reaches the vendor as it is, so name the header the SDK uses.none: wirl adds nothing, and still removes anAuthorizationheader the SDK sends.
An SDK that signs each request with its key, as AWS's do, cannot use a placeholder: wirl sets a header, it does not sign.
wirl deploy and wirl dev bundle a package through its workerd, worker or browser export, the builds made for Cloudflare Workers and for browsers, so an SDK that has one uses it, with fetch underneath, and needs no HTTP client of your own: Stripe's needs no createFetchHttpClient().
When wirl refuses a call itself, the answer is JSON in one shape, {"error": "<code>", "message": "<what is wrong>"}, with a status to match, and the message is written for the person reading your app's error. no_key is a 503: the connection has no key yet, and the message holds the link where someone adds it. No retry can succeed until a person has, so do not retry it: show the message. Some SDKs read a string error as an error of their own and drop the message: Stripe's reads it as an OAuth error, keeps only no_key, and retries the 503 by itself, which only makes the same refused call again. When an SDK says no more than the code, make the same call with fetch and read the body for wirl's message. A 502 from wirl comes after the call may have reached the vendor: too_large and app_answers_full, owner_answers_full or answers_full, where the vendor received the call and answered it and wirl could not pass the answer on, and upstream_error. An SDK that retries a 5xx by itself sends such a call again, and the vendor may act on it twice: for a call that must not happen twice, such as a charge or a message, turn the SDK's retries off, or make the call with fetch, and send it again yourself only once you know that is safe.
The org Connections page#
/orgs/<org>/connections lists every connection an admin holds for the organisation: its name and kind, the hosts it may reach, who added it, and which apps are using it. A connection with no key yet shows a warning. Below the table, "What apps called" shows, by host, how many calls each app made through the gate in the last 30 days and how many were refused or did not complete. A streamed answer counts as a call when its headers arrive, even if it is cut part-way. Of the refused ones, some never left wirl, such as a call whose own body was too large or too slow to send, or one wirl stopped because the bodies it holds at once were at their limit. A call refused because its answer was too large, or as busy, had already reached the vendor, which may bill for it, and one that failed upstream or that its app gave up on may have reached it too. A call whose answer wirl cut because the answers it holds at once were at their limit had reached the vendor too. One wirl did not send, because those answers left too little room for one more, never left wirl.
From that page an admin can revoke an app's grant on a connection (that app's calls to it start being refused at once; the key itself, and every other app's grant on it, are unaffected), grant it again later, or delete the connection outright. Deleting asks you to type the connection's slug to confirm, because there is no undo: every app using it loses access immediately. Its slug stays taken: a deploy or a preview that still declares it is refused, saying so, and a new connection needs another slug. There is no organisation-level form for adding a connection; add one from an app's Connections page, or with wirl connections create.
Trying a connection without its key#
Under wirl dev, a connection's calls can go to a server you run instead of to the vendor, so the app's own code, a vendor's SDK and a streamed answer can all be tried before anyone adds the key. Map the connection to that server's base URL in .wirl/mocks.json, beside wirl.json:
{ "anthropic": "http://127.0.0.1:4010" }
A call the app makes to one of that connection's declared hosts then goes to the base URL instead. The call's path is added to the base URL's own path and its query is kept, so https://api.anthropic.com/v1/messages goes to http://127.0.0.1:4010/v1/messages. The method, the body and every header wirl sends but Host are what the vendor would get. A streamed answer reaches the app as it arrives, within the bounds a vendor's has, and a redirect your server answers with is handed back to the app and never followed, as a vendor's is.
Under wirl dev, a none connection sends your server no key. Any other sends it the key in .wirl/keys.json when that file has one for it, in the shape wirl dev prints for each connection that has none: { "stripe": { "token": "..." } } for http_bearer, a username and a password for basic, and for http_header each header's name and its value, such as { "anthropic": { "x-api-key": "..." } }. When the file has none, your server gets the placeholder wirl-dev-mock in the shape of the connection's kind, so it sees the headers the vendor would: Authorization: Bearer wirl-dev-mock for http_bearer, and wirl-dev-mock as both the username and the password for basic. An http_header connection sends no key header then, since only .wirl/keys.json names the header.
The checks stay as they are. A host the connection does not declare is refused, mapped or not. A connection with no mapping still goes to its vendor, and one with no key is still refused as no_key: the placeholder never goes to it. Through wirl, an address on your own machine or network is reached only as a base URL you mapped, and any other host that resolves to one is still refused. Under wirl dev the app's own call to 127.0.0.1, localhost or [::1] does not go through wirl: it goes there directly, as it always has.
wirl dev reads .wirl/mocks.json once, at start, and says for each mapping where its calls go and which key they carry. It refuses to start, and says why, when the file is not a JSON object of connection names to base URLs or is over 65,536 bytes; when a name in it is not a connection wirl.json declares, or is one that declares no hosts or shares a host with another connection; or when a base URL is not http or https, or has a user name, a password, a query or a fragment in it.
The mapping is wirl dev's alone. A deploy and a preview never upload .wirl/mocks.json or .wirl/keys.json, and nothing deployed reads either.
Tokens#
A token carries one of two scopes. A browser session always issues full. wirl login issues deploy unless the person ticks full access on the approval page.
deploycovers shipping: create an app on first deploy, deploy, roll back, pull source, list apps and versions and connections, set the description and accent, declare a connection the app needs, and read who it is signed in as.fullcovers everythingdeploydoes, plus anything that widens or hands over access, or touches credentials or people: sharing an app, making it private or org, transferring or moving it, deleting it, creating or rotating or deleting a connection, deciding a grant, adding or removing members, and revoking tokens.
Two things no token does, whatever its scope: make an app public, and mint a token. Both are a person's, signed in at a browser on the dashboard: public on the app's Access page (Sharing), and a token on the Connect an agent page, by approving wirl login, or by allowing an MCP client on its consent page. The dashboard's forms answer a token with a 403, so the token an agent holds cannot reach either through them.
Connecting an agent through MCP issues a deploy token unless the person ticks the box on the consent page: "Also give it full access".
The Connect an agent page (/settings/tokens) shows the MCP install command and lists every token with its scope and a revoke link.
If an agent holding a deploy token tries something only full can do, it gets back a 403 saying which action it cannot do and where to do it instead; the CLI and MCP surface that as a plain message telling the person to do it in the dashboard, or to run wirl login again and tick full access on the approval page.