Repository navigation
[Bug]: S3 presigned URLs in messages.attachments / messages.files / toolcalls.attachments expire on read (images 403 after S3_URL_EXPIRY_SECONDS) #13751
Description
What happened?
S3 presigned URLs embedded in persisted message and tool-call snapshots are never refreshed on read. Once a presigned URL expires (default S3_URL_EXPIRY_SECONDS=120, i.e. 2 minutes), any image or file rendered from those snapshots returns 403 SignatureDoesNotMatch and shows as a broken tile in the UI.
User-facing symptom: a user generates an image (or uploads one) in a conversation, leaves it, and on return — or on initial load of any older conversation — the image is gone.
The bug affects three persistence sites that all snapshot the full file record (including the SAS-signed filepath) into denormalized [Mixed] arrays that are never updated after write:
messages.attachments[]— tool outputs fromimage_gen_oai, DALL·E, Flux, Stable Diffusion, code execution, etc. (written byapi/server/controllers/agents/callbacks.jsTools_End handler viasaveBase64Image).messages.files[]— user-uploaded files referenced in a message (written bybuildMessageFilesinpackages/api/src/utils/message.ts).toolcalls.attachments[]— code-interpreter /execute_codeartifacts (packages/data-schemas/src/schema/toolCall.ts:10,40), rehydrated by the client throughuseToolCallsMapon conversation open.
These snapshots are served verbatim by the read endpoints below. The existing refreshS3FileUrls chain (packages/api/src/storage/s3/crud.ts:936) is wired only into GET /api/files and the avatar dispatchers — it never touches messages or tool calls.
Root cause: the following endpoints return persisted documents without re-signing the embedded URLs:
routes/messages.js—GET /(cursor + search branches),GET /:conversationId,GET /:conversationId/:messageId(3 sites).routes/share.js—GET /:shareId(getSharedMessagesresponse).controllers/tools.js—GET /api/agents/tools/calls(getToolCalls).
I have a fix prepared — a small refresh-on-read shim that walks attachments[]/files[] on the response payload and dispatches to the existing refreshS3Url per entry. In-memory only (no Mongo writes), per-call memoization keyed on filepath, errors retain the original URL. Happy to open a PR against dev if maintainers agree on the approach.
Version Information
Reproducible on current main and dev (verified against dev at commit 9efe4878e, "
The bug exists in any release with fileStrategy: "s3" since refreshS3FileUrls was introduced; nothing has wired it into the message or toolcall read paths. Reproducible in self-hosted Docker, source builds, and managed deployments alike.
Steps to Reproduce
- Configure
.envwithfileStrategy=s3, valid S3 creds, andS3_URL_EXPIRY_SECONDS=30(any short TTL; default 120 also reproduces, just takes longer). - Configure an agent with
image_gen_oaiavailable, or use any default chat with image-gen enabled. - Send a message that triggers image generation. Confirm the image renders.
- Wait
> S3_URL_EXPIRY_SECONDS(e.g. 35 seconds with the setting above). - Reload the conversation, or navigate away and back, or open the conversation from a fresh tab.
- Observed: the image tile renders as broken. Browser network panel shows
403 SignatureDoesNotMatchfroms3.amazonaws.com(or your S3-compatible endpoint). - Expected: the image should still render. The blob in S3 is intact; only the URL signature expired.
Same repro applies to:
- User-uploaded images in
messages.files[](upload an image to a message, send, wait, reload). - Code-interpreter outputs in
toolcalls.attachments[](run anexecute_codetool that produces a chart, wait, reload — the chart 403s whenuseToolCallsMaprehydrates it). - Shared conversations carrying any of the above (
anonymizeMessagesinpackages/data-schemas/src/methods/share.ts:60copies the snapshot at share creation, thenGET /api/share/:shareIdreturns it verbatim).
What browsers are you seeing the problem on?
Chrome, Firefox, Safari, Edge — backend issue, browser-independent.
Relevant log output
# Browser DevTools → Network on the image request:
GET https://<bucket>.s3.<region>.amazonaws.com/<basePath>/<userId>/<file>?...&X-Amz-Date=20260615T...&X-Amz-Expires=30&...
→ 403 Forbidden
<?xml version="1.0" encoding="UTF-8"?>
<Error>
<Code>AccessDenied</Code>
<Message>Request has expired</Message>
<X-Amz-Expires>30</X-Amz-Expires>
...
</Error>
# Backend has no log for this — LibreChat is not in the request path. The
# signed URL was served from MongoDB on the prior GET /api/messages call;
# S3 enforces expiry directly against the client fetch.
Screenshots
n/a — broken image tile in conversation history. Happy to attach if useful.
Code of Conduct
- I agree to follow this project's Code of Conduct
Additional context
Why this matters at default settings: DEFAULT_EXPIRY_SECONDS = 2 * 60 in packages/api/src/storage/s3/s3Config.ts. Any user opening a conversation older than two minutes hits this on every image in that conversation.
Adjacent open work: #13740 (Snapshot Files for Shared-Link Attachments) introduces a fileSnapshots[] model on SharedLink and applies a read-time URL rewrite in getSharedMessages for share-scoped serving. That work targets a different concern (auth boundaries for anonymous viewers), but it shares the "rewrite URLs on the response" pattern — so coordinating on routes/share.js would avoid conflicts when both land.
Activity
Falenos commentedon Jun 15, 2026
Adding cross-links to prior user reports of this same bug for searchability:
Adjacent (fixed already): #11445 — that one was the agent-avatar-only slice and was resolved by the existing
refreshS3FileUrlschain, but the message/toolcall snapshot sites were never wired up.