Engineering NotesSecurity and audit
Keeping presigned URLs out of the model with 19-character references

Connectors for cloud file services (external services, such as MCP servers, that a company connects to OwlAI) return download links as pre-authenticated URLs, also called presigned URLs. Each one carries a live access token in full and can run past 2,000 characters. To fetch the file into the sandbox, the model has to copy that URL character by character into the next tool call's arguments, and when the copy falls apart, the turn (one question answered, start to finish) fails. Even when the copy succeeds, the token sits in plain text in the request to the model provider, in the conversation context, in on-screen previews and in the turn event log.
So OwlAI does not show these URLs to the model. The server finds token-bearing URLs in tool results, stores them encrypted and puts a 19-character reference in their place. The model copies the reference as is, and the tool that fetches the file turns the reference back into the original URL before it sends the request. This post covers which URLs are hidden, how the place that hides them is matched with the places that restore them, and who can redeem a reference and where. The vault that encrypts stored credentials is covered in “Database backups carry stored passwords, so the key lives elsewhere”.
When the model copies the URL, it fails and leaks at once
The copy breaks more often than you might expect. In one failed turn observed in production, the model kept rewriting the token's signature and poured stray monologue into the argument. It spent 16 seconds and more than 1,300 streaming chunks on a single argument, and the turn failed. A token is a meaningless string, so the model can't repair it from context either.
A shorter URL still leaks. Once the model sees the URL, the token goes out in the request to the model provider, stays in the conversation context and goes out again on every later turn. An instruction hidden in a document can also tell the model to send that URL somewhere else (“Permissions are enforced in code, judgment is left to the model”). Until the token expires, anyone holding the URL can download the file.
There are two common fixes. One is to delete the URL from the tool result. Values the model never uses again, such as the file bytes returned by a tool that renders a document, can be deleted (“Scrubbing answers is too late for phone numbers. We mask tool results”). A download URL, though, is the input to the next tool call, and deleting it breaks the task on the spot. The other is to show the URL to the model and hide it only in logs. That does nothing about the token going to the provider, and the garbled copies stay.
So the server holds the URL, and the model sees only a reference that points to it.
Length and credential markers decide which URLs to hide
The server hides two kinds of HTTPS URLs. One is a URL long enough that copying it is risky; the other is a URL that contains a signature or an access token. The first guards against garbled copies even when no credential is present, and the second guards values that must not reach prompts or logs even when the URL is short.
Ordinary URLs must match neither. The model can't show a hidden URL in an answer as a source link (“Every answer source is a URL, and the server builds the list”), and it can't tell from the URL which site or document it points to. So the criteria were tuned against real links. Document links with Korean paths and search result links with tracking parameters must stay visible even when they are fairly long, and so must page links whose ordinary parameters have names similar to token parameters. Hiding them would take away the sources the user needs to see.
The server hides URLs in every tool result at one point
Chat, playbooks, deep research and meetings all run the same agent loop, and every tool result passes through the same tool executor (“One agent loop for every feature, with the differences in hooks”). Hiding is one of the steps in which the executor cleans up a result. It runs after binary data is replaced with a size marker and before personal data is masked and large results are moved to a file (“Scrubbing answers is too late for phone numbers. We mask tool results”). Whether the tool comes from a connector, ships with the product or was just added, the URLs in its result are hidden by the same criteria.
One tool result goes to several places. The structured result that becomes model input, the text that goes to on-screen previews and the turn event log, and even warning messages attached by the remote server are hidden in the same step. Miss any one of them and the token survives along that path.
An MCP server's response sometimes arrives with the entire remote result packed into one JSON string. Finding and replacing URLs in that string with a regular expression alone could break the JSON's escaping, so a string that parses as JSON is unpacked, scanned all the way in and reserialized. If nothing changed, the original string stays as it was. A needless change in the result's shape would keep prompt caching from working past that point in the next round.
The same URL can appear several times in one result, and a later call can return the same URL again. When the same user deposits the same URL again, the server returns the reference it already issued and extends its lifetime. If the reference changed every time, the model couldn't use a reference from an earlier step in a later one.
Every tool that takes a URL restores references
Hiding covers every tool result, so restoring has to be in every tool that takes a URL as an argument. If even one tool lacks it, that tool sends the reference string to an outside API as is, pays for the call and fails. The model has never seen the original URL, so it can't fix the call itself.
Three tools take URLs today.
| Tool | Argument that accepts a reference |
|---|---|
| Tool that fetches a file from a URL into the sandbox | URL to fetch |
| Tool that uploads a sandbox file to a URL | URL to upload to (an upload presigned URL) |
| Web content extraction tool | List of URLs to read |
A single test pins this list. It scans every registered tool schema for arguments that take URLs and fails if that set differs from the list above. A missing restore step tends to appear not when someone edits a line of code but when someone adds a tool. If a new tool gets a URL argument, the test passes only after someone confirms that the tool restores references. The argument descriptions of all three tools tell the model to copy the reference verbatim, because the schema description is the only place the model can learn that.
Checks after restoring run on the original URL. The file fetch tool applies its guard against requests to internal addresses (SSRF) to the URL it will actually fetch, not to the reference, and applies it again to every address a redirect moves to. A URL resolved from a reference passes the source check (“A URL in a tool result stays closed if the model wrote it first”), because references are created only from tool results.
Some remote servers echo the requested URL back in the body of an error. The file fetch tool trims that URL to its host name before passing the error to the model. When the refusal looks like a credential problem, it also uses the issuing connector's name, stored with the reference, to say which connection to check.
If a reference can't be restored, the tool returns an error telling the model to call the tool that produced the URL again and get a new reference. The message points only to calling that tool again because the model doesn't know the original URL. If it tried to make one up, the copying problem would be back.
Only the user it was issued to can redeem a reference
A reference is short and regular, the kind of string a model can make up. So when the server restores a reference, it checks that the requesting user is the one the reference was issued to. Guessing someone else's reference correctly still does not bring back their URL.
A nonexistent reference, an expired one and someone else's all get the same response. Telling them apart would leak "that reference exists, but it isn't yours," a foothold for an enumeration attack that hunts for other users' references.
Originals sit encrypted in one database
References stay in the conversation context and are used again in later turns. A user can ask in the next question to fetch a file that came up in the previous one. The next turn may run in a different worker process or on a different server, so the server keeps the original URL in one place, OwlAI's metadata database. Whichever process receives the request to restore it gives the same answer. Expiry is judged by the database clock too, so slightly different clocks on different servers don't produce different answers.
The original URL is not stored in plain text. It is encrypted with the same vault key as other credentials (“Database backups carry stored passwords, so the key lives elsewhere”). Whether the same user has already deposited the same URL is looked up with an HMAC keyed by an installation secret. With a plain hash, anyone who could read the database could hash candidate URLs and compare. Without the installation secret, nothing is stored. The store's logs do not include raw URLs. When a write fails, the log keeps the exception type and leaves out the message, because database driver exception messages include the values bound into the query.
The default reference lifetime is 1 hour, and each installation can change it in its settings. When a new reference is issued, that user's expired rows are deleted, and a periodic job sweeps the whole installation again. Stored rows have a per-user cap and an overall cap.
Design choices
We kept the set of hidden URLs narrow. Hiding every URL would make the criteria simpler and leak nothing, but search results and document links could no longer serve as source links, and the model couldn't look at a URL to decide which page to read. So length and credential markers pick out only token-bearing URLs. If a connector returns URLs these criteria can't catch, an administrator sets Require approval or Block on that tool.
We keep original URLs in the database instead of process memory. Memory would be faster and simpler, but when the next turn runs in another process, that turn treats the reference as unknown and the task breaks. References are issued only from new tool results that contain URLs to hide, which doesn't happen often. So we chose the same answer wherever the turn runs over saving one database read.
We set the lifetime to 1 hour. The presigned URLs that connectors return usually expire in about an hour, so holding them longer means storing URLs that no longer work. When a long task hits an expired reference, the model follows the instruction, calls the tool that produced the URL again and continues with a new reference. The cost is one extra tool call.
The per-user cap comes first, ahead of an overall cap alone. With only an overall cap, one user's turn that calls many tools would push out references another user just received, and that user couldn't restore them within their own turn. Capping per user keeps the impact inside that user's turn. The overall cap keeps rows from growing without bound on installations with many users.
If the store can't be written, a placeholder goes in place of the original URL. Sending the original would keep the task going, but it would bring back exactly what this design prevents: garbled copies and plain-text tokens. The placeholder tells the model to call the tool that produced the URL again, so once the store is back, the model gets a new reference and continues.
If your agent passes a connector's download links on to other tools, start by checking whether the presigned URLs in tool results reach model input and logs as is. If they do, replace them with short references at the one point where results come in, make every tool that takes a URL restore those references, and pin that list of tools with a test.
What problem does this solve?
More in Security and audit

Engineering Notes · Security and audit
External models never see internal-only data, not even its name
Put a model running inside the customer network and an external API model in one installation, and deciding wh…

Engineering Notes · Security and audit
A URL in a tool result stays closed if the model wrote it first
Tools that read a web page or download a file from a URL only open addresses that appeared earlier in the conv…

Engineering Notes · Security and audit
Scrubbing answers is too late for phone numbers. We mask tool results
"Find the customers who asked for a refund more than three times last week and summarize what they said" is no…
