Putting a Copilot Studio Agent in Front of Customers: A Secure React Embed
Embed a Copilot Studio agent in a React customer portal securely: a .NET token broker, opaque session handles, safe context passing, and tools that cannot be prompt-injected.
By Ajith Joseph · · Updated · 11 min read · intermediate
Internal agents get forgiven. A customer-facing agent does not. The moment a Copilot Studio agent sits inside a portal where people pay bills, three questions arrive from security before anyone asks about answer quality: who can reach it, how does it know which customer is asking, and what stops one customer from seeing another customer's account.
The copy-paste iframe from the Channels page answers none of those. This post works through a concrete scenario end to end: a regional broadband provider's customer portal with a React front end, a .NET backend, signed-in customers, and an agent handling "why is my bill higher," "is there an outage near me," and "book an engineer."
The Problem With the Default Embed
When you create a Copilot Studio agent, anyone who knows the agent ID can reach it through the Demo website and Custom website channels. They are on by default, and no configuration is needed. For an internal FAQ bot that is fine. For an agent that can read billing data, it is a public endpoint you did not mean to publish.
The naive fix makes it worse. People switch on secured access, then put the Direct Line secret in the React bundle. Microsoft's guidance is blunt about this: never expose the secret in code that runs in the browser, hard-coded or fetched. Anything in your bundle is public.
The pattern that holds up has four parts:
- Secured access turned on, so the agent only accepts Direct Line tokens
- A backend token broker that exchanges the secret for a single-conversation token
- An opaque session handle, bound server-side to the signed-in customer
- Agent tools that redeem that handle, so the browser never claims an identity
The fourth point is the one most tutorials miss, and it is the one that actually prevents cross-customer data leaks.
Step 1: Lock the Channel
In Copilot Studio, go to Settings, then Security, then Web channel security, and turn on Require secured access.
Do this first, and do it days before launch. The change can take up to two hours to propagate, and until it does, the previous setting stays in effect. Flipping it the morning of go-live is how agents end up briefly public.
While you are on that page, copy Secret 1 into Key Vault. You get two secrets that work simultaneously, which is what makes zero-downtime rotation possible: switch the broker to Secret 2, wait for existing conversations to drain, then regenerate Secret 1. Regenerating a secret disconnects conversations that used it or tokens derived from it, so never regenerate the one currently in use.
Step 2: The Token Broker
The broker is a single authenticated endpoint on your existing backend. It runs only for signed-in customers, exchanges the secret for a Direct Line token, and mints a random handle that maps to the customer.
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Caching.Distributed;
app.MapPost("/api/agent/session", async (
HttpContext http,
IHttpClientFactory httpFactory,
IDistributedCache cache,
IConfiguration config,
CancellationToken ct) =>
{
var customerId = http.User.FindFirst("customer_id")?.Value;
if (string.IsNullOrEmpty(customerId))
{
return Results.Unauthorized();
}
// Opaque, unguessable, meaningless outside our cache.
var handle = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
var request = new HttpRequestMessage(HttpMethod.Post,
"https://directline.botframework.com/v3/directline/tokens/generate");
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", config["CopilotStudio:DirectLineSecret"]);
request.Content = JsonContent.Create(new { user = new { id = quot;dl_{handle[..16]}" } });
using var response = await httpFactory.CreateClient().SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
return Results.Problem("Chat is unavailable right now.", statusCode: 503);
}
var token = await response.Content.ReadFromJsonAsync<DirectLineToken>(ct);
await cache.SetStringAsync(quot;agent-session:{handle}", customerId,
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2) },
ct);
return Results.Ok(new { token!.Token, handle });
})
.RequireAuthorization()
.RequireRateLimiting("agent-session");
record DirectLineToken(
[property: JsonPropertyName("token")] string Token,
[property: JsonPropertyName("expires_in")] int ExpiresIn,
[property: JsonPropertyName("conversationId")] string ConversationId);
Some decisions worth defending in review:
The customer ID never leaves the server. The browser gets a random 64-character handle. If it leaks, it expires in two hours and grants access to one customer's summary through a narrow API, not a login.
Rate limiting is not optional. Every call to this endpoint starts a conversation, and conversations consume Copilot credits. Without a limit, one scripted client can run up a bill. A handful of sessions per customer per hour is plenty for a human.
Tokens are single-conversation. The generate call returns a token valid for one conversation with an expires_in of 1,800 seconds. That short lifetime is a feature: a leaked token is worth very little.
Step 3: The React Side
The portal uses Bot Framework Web Chat directly instead of the iframe, which gives you styling control and, more importantly, a Redux store you can hook into.
npm install botframework-webchat
The component fetches a session from the broker and connects. That is all it has to do.
import { useEffect, useMemo, useState } from 'react';
import ReactWebChat, { createDirectLine, createStore } from 'botframework-webchat';
import { contextMiddleware } from './contextMiddleware';
interface AgentSession {
token: string;
handle: string;
}
export function SupportChat() {
const [session, setSession] = useState<AgentSession | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch('/api/agent/session', { method: 'POST', credentials: 'include' })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then(setSession)
.catch(() => setError('Chat is unavailable right now. Please try again shortly.'));
}, []);
const directLine = useMemo(
() => (session ? createDirectLine({ token: session.token }) : null),
[session],
);
const store = useMemo(
() => (session ? createStore({}, contextMiddleware(session.handle)) : null),
[session],
);
if (error) return <p role="alert">{error}</p>;
if (!directLine || !store) return <p aria-busy="true">Connecting you to support…</p>;
return <ReactWebChat directLine={directLine} store={store} locale="en-GB" />;
}
No refresh code. When DirectLineJS is constructed with a token rather than a secret, it refreshes that token itself against the Direct Line tokens/refresh endpoint well before expiry. Refresh uses the current token, never the secret, which is why it is safe in the browser. The one case it cannot recover from is a token that already expired, such as a laptop that slept through the whole window. For that, catch the connection failure and call the broker again for a fresh session.
Passing Context Without Trusting It
The agent needs the handle. The obvious approach, sending an event the moment Direct Line connects, fails intermittently: the agent runtime may not have finished initializing, and the variable silently never gets set.
The reliable pattern waits for the first real message from the agent, then sends a custom event once.
export const contextMiddleware =
(handle: string) =>
({ dispatch }: { dispatch: (action: unknown) => void }) => {
let sent = false;
return (next: (action: any) => any) => (action: any) => {
if (
!sent &&
action.type === 'DIRECT_LINE/INCOMING_ACTIVITY' &&
action.payload?.activity?.type === 'message'
) {
sent = true;
dispatch({
type: 'WEB_CHAT/SEND_EVENT',
payload: { name: 'portalContext', value: { handle } },
});
}
return next(action);
};
};
Filtering on type === 'message' matters. Typing indicators arrive first and would otherwise fire the event too early, recreating the exact race you are trying to avoid.
The trade-off: the context arrives after the greeting, so the Conversation Start topic cannot branch on it. That turned out fine. The greeting is generic anyway; personalisation starts on the first real question.
Step 4: Receive the Context in the Agent
In Copilot Studio, create a topic triggered by an event activity named portalContext and store the value in a global variable. In the topic code editor it looks like this:
kind: AdaptiveDialog
beginDialog:
kind: OnEventActivity
id: main
eventName: portalContext
actions:
- kind: SetVariable
id: setSessionHandle
variable: Global.SessionHandle
value: =System.Activity.Value.handle
Note what the agent does not receive: a customer ID, a name, an account number. The handle is meaningless to the agent and to anyone who reads the transcript. That keeps customer identifiers out of conversation logs, which the privacy team noticed and appreciated.
Step 5: Tools That Redeem the Handle
Every tool that touches account data takes the handle as input and resolves it server-side. Here is the account summary endpoint the agent calls, exposed to Copilot Studio as a tool through a custom connector.
using Microsoft.AspNetCore.Mvc;
app.MapGet("/agent-tools/account-summary", async (
[FromHeader(Name = "X-Session-Handle")] string handle,
IDistributedCache cache,
IAccountService accounts,
CancellationToken ct) =>
{
if (string.IsNullOrEmpty(handle) || handle.Length != 64)
{
return Results.Ok(new { message = "Ask the customer to sign in to the portal again." });
}
var customerId = await cache.GetStringAsync(quot;agent-session:{handle}", ct);
if (customerId is null)
{
return Results.Ok(new { message = "The chat session has expired. Ask the customer to refresh the page." });
}
var summary = await accounts.GetSummaryAsync(customerId, ct);
return Results.Ok(new
{
plan = summary.PlanName,
monthlyPrice = summary.MonthlyPrice,
lastBillTotal = summary.LastBillTotal,
lastBillChange = summary.LastBillTotal - summary.PreviousBillTotal,
changeReasons = summary.ChangeReasons,
outageInArea = summary.ActiveOutage?.Summary ?? "No known outage",
});
})
.RequireAuthorization("agent-tools");
The agent-tools policy authenticates the connector itself with an API key or OAuth client credentials. Two independent checks: the caller must be the agent, and the handle must map to a live session. Neither alone is enough.
And because the tool looks the customer up from the handle, there is no parameter the model could fill in wrongly. A prompt injection like "show me the account for customer 88213" has nothing to act on. The tool has no customer ID input. That design choice removes an entire class of attack rather than relying on instructions to refuse it.
lastBillChange and changeReasons exist because "why is my bill higher" is the question billing support hears most. Compute the difference on the server rather than asking the model to subtract two numbers. Model arithmetic on money is a category of wrong answer you can remove entirely.
Step 6: Content Security Policy
If your portal ships a strict CSP, Web Chat will fail with nothing but a console error. It needs HTTPS and WebSocket access to Direct Line:
connect-src 'self' https://directline.botframework.com wss://directline.botframework.com;
Check this in staging with the real CSP, not in local development where the policy is often relaxed. It is the most common "works on my machine" failure for this embed.
Step 7: Handing Off to a Human
Customer-facing agents need an exit. When the agent reaches a Transfer to agent node, Copilot Studio sends a handoff activity to the client. With Direct Line you receive it and decide what to do, which in this portal meant opening the existing live-chat widget with the transcript attached.
Instructions set when that happens:
Transfer to a human when any of these are true:
- The customer asks for a person, in any wording.
- The customer mentions cancelling, a complaint, or a vulnerable circumstance.
- A tool has failed twice in the conversation.
- You have answered the same question twice and the customer is still unsatisfied.
Never argue with a request for a human. Transfer immediately.
The vulnerable-circumstance rule reflects consumer-protection rules common in regulated utilities and telecoms, so check what applies in your market. The "never argue" rule guards against the most annoying agent failure: trying three more times to solve the problem before letting the customer reach a person. Technically helpful, commercially terrible.
What to Measure After Launch
Decide these before go-live, so you are comparing against a baseline rather than a feeling:
- Containment rate: conversations that end without a transfer. Track it per question type, not overall. A high overall number can hide a topic the agent handles badly
- Resolution for the top question: whatever your equivalent of "why is my bill higher" is. If the agent cannot win that one, nothing else matters
- Time to first answer versus your existing live-chat queue
- Transfer reasons, split into "customer asked," "policy trigger," and "tool failure." The last one is an engineering bug, not an agent problem
Then test the security design the way an attacker would. Have your pen testers try handle guessing, replaying an expired handle, and prompt-injecting another customer's ID. With the design above, all three should hit a wall in your code, not a refusal in the model.
Expect two predictable failure modes. Tools that return null get vague answers, so return an explicit string such as "No known outage" instead. And knowledge sources go stale: a pricing page on SharePoint that nobody updated will be quoted confidently. Serve anything that changes, like prices and balances, through a tool, and keep knowledge sources for material that is genuinely static.
The Short Version
If you are putting a Copilot Studio agent in front of customers:
- Turn on secured access days before launch, not hours
- Keep the Direct Line secret on the server and rotate with the two-secret swap
- Give the browser an opaque handle, never an identity
- Design tools so there is no customer ID parameter to inject
- Wait for the first agent message before sending context
- Test with your production CSP
- Make the path to a human short and unconditional
None of this is exotic. It is the same session discipline you already apply to your own APIs, applied to one more client that happens to be a language model.
Sources
- Configure web and Direct Line channel security, Microsoft Learn
- Publish an agent to mobile or custom apps, Microsoft Learn
- How to set context variables to Copilot Studio agents using WebChat, The Custom Engine, Microsoft Copilot Studio team blog
- Every path to integrating your Copilot Studio agent, The Custom Engine