Link Unfurling in Teams Without Leaking What Users Can't See
Build a Teams link-unfurling message extension that resolves internal URLs with per-user auth and an access-scoped cache, so previews never leak a restricted resource.
- #microsoft-teams
- #ai
- #message-extensions
- #link-unfurling
- #security
Link unfurling is one of those features that looks like a quick win and quietly hides two of the nastiest problems in app design: identity and caching. When someone pastes https://incidents.internal/INC-0142 into a Teams channel, your extension can turn that bare URL into a rich card showing the incident’s title, severity, and owner. Lovely. But whose view of that incident are you showing, and what happens when the same link gets pasted ten times during a war room? Get those two questions wrong and your helpful integration becomes an accidental data-leak channel. This post is about getting them right.
How unfurling fires
You register URL patterns in your app manifest under composeExtensions.messageHandlers. When a user types a matching URL, Teams sends your bot a composeExtension/queryLink invoke, and you respond with a preview Adaptive Card.
"composeExtensions": [
{
"botId": "${{BOT_ID}}",
"messageHandlers": [
{ "type": "link", "value": { "domains": ["incidents.internal"] } }
]
}
]
The handler receives the URL and returns a card:
async function handleQueryLink(context) {
const url = context.activity.value.url;
const incidentId = parseIncidentId(url);
// resolve metadata AS the pasting user — see below
const meta = await resolveAsUser(incidentId, context);
if (!meta) return noAccessCard(); // graceful denial
return previewCard(meta);
}
That resolveAsUser call is where the whole design lives or dies.
Unfurling runs as the user, so resolve as the user
The pasting user’s identity is the one that matters. If Alice pastes a link to an incident she can see and Bob can’t, and your extension resolves metadata with a service-account identity that can see everything, then when Bob pastes the same link he gets a preview of a resource he has no business viewing. The card just leaked a title, a severity, maybe a customer name — to someone who lacks access.
The fix is to resolve with the pasting user’s identity, using SSO and the on-behalf-of flow to call your backend as that user:
async function resolveAsUser(incidentId, context) {
const userToken = await getSsoToken(context); // Teams SSO
const apiToken = await exchangeOnBehalfOf(userToken); // OBO to your API
const res = await fetch(`${API}/incidents/${incidentId}`, {
headers: { Authorization: `Bearer ${apiToken}` },
});
if (res.status === 403 || res.status === 404) return null;
return res.json();
}
Now the preview reflects exactly what the pasting user is allowed to see. If they lack access, the backend returns 403/404, you return null, and the handler renders a generic no-access card — never the resource’s real title or details. That graceful denial matters: an error or, worse, a leaked title is a far worse experience than a clean “you don’t have access to this item” card.
The cache is where the leak hides
Here’s the trap. Unfurling the same URL ten times in a busy channel means ten backend calls unless you cache, and you absolutely want to cache. But the obvious cache — keyed by URL alone — is a security bug. Cache Alice’s authorized preview under the URL, and the next time Bob pastes that URL, your cache happily serves him Alice’s view. You’ve turned a performance optimization into a permission bypass.
The cache key has to include the user’s visibility scope:
function cacheKey(url, context) {
// scope by something that captures the user's access, e.g. their object id
// or, better, a derived "visibility group" so users with identical access share entries
return `${url}::${context.activity.from.aadObjectId}`;
}
async function resolveCached(url, context) {
const key = cacheKey(url, context);
const hit = cache.get(key);
if (hit) return hit;
const meta = await resolveAsUser(parseIncidentId(url), context);
cache.set(key, meta, { ttl: 60 }); // short TTL — metadata goes stale
return meta;
}
Keying by user object id is the safe-but-blunt version: every user gets their own cache entry, so there’s no cross-user leak, but the hit rate is lower. If you want better hit rates, derive a “visibility scope” — a hash of the user’s relevant access groups — so users with identical permissions share cache entries while users with different access never do. Either way, the rule is absolute: the cache key must encode access, never URL alone.
The TTL is short on purpose. Incident metadata changes — severity escalates, ownership moves — and a preview that’s an hour stale is misleading during exactly the moment people are pasting it. Sixty seconds is a reasonable starting point; tune it against how fast your resources actually change.
Letting AI draft it, then auditing the cache key
I’ll happily have an AI assistant draft the handler and cache layer, but the one thing I audit by hand every time is the cache key, because that’s where a confident, plausible-looking answer creates a leak that no test will catch unless you specifically look for it.
Prompt: “Write a Teams link-unfurling queryLink handler that resolves internal URLs via SSO and on-behalf-of as the pasting user, returns a no-access card on 403, and caches resolved metadata. Make the cache key include the user’s access scope so one user’s preview is never served to another.”
Output (excerpt): A handler that performs the OBO exchange, returns
null/no-access on a 403, and a cache wrapper keying entries by URL plus the user’s object id with a 60-second TTL.
The model produces a working handler. What I verify is that the cache key genuinely includes the access dimension and that the 403 path returns a generic card with no leaked fields — the two places where a subtle mistake is a security issue, not a bug.
Stay inside the latency budget
Teams expects a fast response to the queryLink invoke. If your backend is slow, return a lightweight card rather than blocking until the full metadata resolves — a card with just the incident id and a “loading details” note beats a card that never arrives because Teams timed out the invoke. Speed and safety aren’t in tension here; the cache helps both.
Pulling it together
Resolve as the pasting user via SSO and OBO, deny gracefully with a no-leak card, and — above all — key your cache by access scope so a restricted resource’s preview can never reach a user who lacks access. For the broader self-service message-extension patterns this builds on, the Microsoft Teams category collects the related work, and the prompts library has a link-unfurling prompt that bakes in the access-scoped cache so you start from a safe design instead of retrofitting one. The feature is a quick win only if you treat its two hidden problems — identity and caching — as the main event.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.