Security Trimming with Microsoft 365 Copilot: Asking the Right Data in the Right Context
Security trimming is not a Copilot feature you add at the end.
It is the basic rule that decides whether a user may see a piece of information before an answer is built from it. If you get that wrong, Copilot does not become evil. It becomes fast. Fast enough to surface permission mistakes that used to stay hidden behind bad search, tribal knowledge or a folder nobody clicked.
That is why I treat security trimming as part of the connector design, not as a prompt-engineering exercise.
The practical question is simple:
When this user asks this question, which sources may the system use, and why?
If your implementation cannot answer that, it is not ready for production.
What security trimming means here
In this context, security trimming means filtering content based on the current user's rights before the content is used for search, retrieval or answer generation.
It is not enough that the backend can access the data. The backend must access it on behalf of the user, or at least enforce a permission model equivalent to what the user should have.
A good trimmed answer has three properties:
- It only uses sources the user is allowed to access.
- It refuses or narrows questions that cross sensitive boundaries.
- It cites sources the user can actually open.
The third point is underrated. If Copilot gives an answer from a source the user cannot open, you either have a citation problem or a permission problem. Both are bad.
Native Microsoft 365 content
For content already inside Microsoft 365, the platform gives you a useful starting point.
A rough flow looks like this:
flowchart LR
U[User] --> C[Copilot]
C --> G[Microsoft Graph]
G --> D[(M365 Data)]
The user asks Copilot. Copilot works through Microsoft Graph and the Microsoft 365 permission model. If the user cannot access a document, that document should not become part of the answer.
That is why tenant hygiene matters before rollout.
Copilot will not fix:
- SharePoint sites with broad member groups
- old Teams with stale guests
- files shared with “everyone” years ago
- confidential documents stored in normal project spaces
- libraries without clear owners
If those permissions are wrong, security trimming will faithfully reflect the wrong model.
My first implementation step is therefore not code. It is permission cleanup.
External systems are different
External systems are where teams usually get into trouble.
A typical pattern looks like this:
flowchart LR
U[User] --> C[Copilot]
C --> A[AskExternalData / Agent]
A --> B[Backend]
B --> AUTH[Auth / Directory]
B --> EXT[External System]
EXT --> B
B --> C
The backend might talk to Confluence, Jira, SAP, SQL, an internal API or a document store. Copilot does not automatically know that system's permissions. Your backend has to enforce them.
The lazy version is this:
- Copilot calls your API.
- Your API uses a service account.
- The service account can read everything.
- The model answers from everything.
That is not security trimming. That is a god-mode search endpoint with a polite chat interface.
The safer version is:
- Copilot passes user context.
- The backend resolves that user against Entra ID or your IAM.
- The backend searches only allowed spaces, documents or records.
- The backend filters the results again before answer generation.
- The response includes only sources the user can open.
- The decision is logged.
It is more work. That is the cost of connecting real company data.
API contract
I would keep the tool contract boring and explicit.
// Request payload from Copilot plugin or custom agent
interface AskExternalRequest {
question: string;
userEmail: string;
projectKey?: string;
}
interface AskExternalResponse {
answer: string;
sources: { title: string; url: string }[];
missingInfo: boolean;
}
In a real implementation, I would prefer a trusted identity claim over a free-form userEmail string. The example keeps it readable, but do not let clients spoof the user in production.
The important part is that the backend receives enough context to make a permission decision.
Resolve the user
The backend needs a local view of the user: roles, groups, project assignments, department, maybe region or legal entity depending on the data source.
// src/permissions.ts
export type UserContext = {
email: string;
roles: string[]; // e.g. ['EMPLOYEE', 'HR', 'ENGINEERING_MANAGER']
groups: string[]; // e.g. ['project-phoenix', 'dept-engineering']
};
export async function getUserPermissions(email: string): Promise<UserContext | null> {
// In reality: query Entra ID, your IAM, or a domain-specific permission service
const entry = await directoryLookup(email);
if (!entry) return null;
return {
email,
roles: entry.roles,
groups: entry.groups
};
}
This should not be a one-off helper buried inside the connector. Treat it as shared security code. If five agents implement permission lookup in five different ways, you will eventually get five different answers.
Filter documents by ACL
For document-like sources, a simple ACL model is often enough to explain the pattern.
// src/acl.ts
import { UserContext } from './permissions';
export type DocumentAcl = {
allowedRoles?: string[];
allowedGroups?: string[];
forbiddenRoles?: string[];
};
export type ExternalDoc = {
id: string;
title: string;
url: string;
content: string;
acl: DocumentAcl;
};
function hasAccess(doc: ExternalDoc, user: UserContext): boolean {
const { allowedRoles, allowedGroups, forbiddenRoles } = doc.acl;
if (forbiddenRoles && forbiddenRoles.some(r => user.roles.includes(r))) {
return false;
}
if (!allowedRoles && !allowedGroups) {
return true;
}
if (allowedRoles && allowedRoles.some(r => user.roles.includes(r))) {
return true;
}
if (allowedGroups && allowedGroups.some(g => user.groups.includes(g))) {
return true;
}
return false;
}
export function filterDocsByAcl(docs: ExternalDoc[], user: UserContext): ExternalDoc[] {
return docs.filter(doc => hasAccess(doc, user));
}
This is intentionally boring. Security code that nobody understands is not automatically safer.
Secure handler pattern
The handler should refuse unknown users, trim before generation, and avoid pretending that missing access is missing knowledge.
// src/secureAsk.ts
import { Request, Response } from 'express';
import { getUserPermissions } from './permissions';
import { searchExternalDocs } from './externalDocs';
import { buildAnswerFromDocs } from './rag';
export async function secureAskHandler(req: Request, res: Response) {
const body = req.body as AskExternalRequest;
if (!body.question || !body.userEmail) {
return res.status(400).json({ error: 'question and userEmail are required' });
}
const user = await getUserPermissions(body.userEmail);
if (!user) {
return res.status(403).json({ error: 'unknown_user' });
}
const docs = await searchExternalDocs(body.question, user, body.projectKey);
if (docs.length === 0) {
return res.json({
answer: `I couldn't find any documents you are allowed to see that answer "${body.question}".`,
sources: [],
missingInfo: true
} satisfies AskExternalResponse);
}
const { answer, usedDocs } = await buildAnswerFromDocs(body.question, docs);
return res.json({
answer,
sources: usedDocs.map(d => ({ title: d.title, url: d.url })),
missingInfo: false
} satisfies AskExternalResponse);
}
The sentence in the empty result matters:
I couldn't find any documents you are allowed to see.
Not:
This information does not exist.
Those are different claims. In enterprise systems, the difference matters.
Secure Confluence example
For a wiki source like Confluence, I would not search everything and filter afterwards if I can avoid it. I would restrict the query as early as possible.
// src/projectSpaces.ts
const projectSpaceMap: Record<string, string> = {
'project-phoenix': 'PHX',
'project-orion': 'ORI'
};
export function getAllowedSpacesForUser(user: UserContext): string[] {
const spaces = new Set<string>();
for (const group of user.groups) {
const spaceKey = projectSpaceMap[group];
if (spaceKey) spaces.add(spaceKey);
}
spaces.add('COMPANY');
return Array.from(spaces);
}
Then use those spaces in the actual search query:
import { getAllowedSpacesForUser, UserContext } from './permissions';
export async function searchConfluenceSecure(query: string, user: UserContext): Promise<ConfluencePage[]> {
const spaces = getAllowedSpacesForUser(user);
const cqlParts = [`text ~ "${query.replace(/"/g, '\\"')}"`];
if (spaces.length > 0) {
const spaceFilter = spaces.map(s => `space = "${s}"`).join(' OR ');
cqlParts.push(`(${spaceFilter})`);
}
const cql = cqlParts.join(' AND ');
// Call Confluence with restricted CQL, then still apply local result filtering.
}
I still like a second filtering step after retrieval. Defense in depth is not glamorous, but it catches mapping mistakes.
Topic guards before retrieval
ACLs decide which documents a user may see. They do not always decide whether a question should be answered in that channel.
For some topics, I would block or route before retrieval.
Example:
function isForbiddenQuestion(question: string, userRoles: string[]): boolean {
const lower = question.toLowerCase();
const sensitivePatterns = [
'salary',
'compensation',
'bonus',
'layoff',
'termination list',
'performance review',
'investigation'
];
const isSensitive = sensitivePatterns.some(p => lower.includes(p));
if (!isSensitive) return false;
const privilegedRoles = ['HR', 'HR_ADMIN', 'LEGAL', 'C_LEVEL'];
const isPrivileged = privilegedRoles.some(r => userRoles.includes(r));
return !isPrivileged;
}
This is not a complete policy engine. It is a reminder that retrieval control and topic control are different things.
For sensitive workflows, I would rather route the user to a proper HR, legal or finance process than let a generic assistant improvise around it.
Common mistakes
These are the mistakes I would actively look for in a review.
Service account can read everything
This is the big one. If the connector uses a god-mode account and does not enforce user-level trimming, the user interface is lying about security.
Filtering after answer generation
Do not generate the answer first and then remove sources. The model has already seen the data.
Returning sources the user cannot open
If the answer cites a page the user cannot open, fix the connector. Either the citation is wrong or the permission check is wrong.
Treating groups as static
Group membership changes. Project access changes. People move departments. Do not cache permission decisions forever.
Logging too much
Do not dump full prompts, retrieved documents or sensitive answer payloads into generic logs. Log the decision, not the secret.
No negative tests
Most teams test with an admin or with a user who should have access. That proves very little. Test with users who must not have access.
Test cases I would run
Before production, I would run at least these tests:
| Test | Expected result |
|---|---|
| User with access asks about allowed project | Answer with allowed sources |
| User without access asks same question | No answer from restricted docs |
| User with old/stale group asks question | Denied if access was removed |
| Non-HR user asks compensation question | Refusal or approved routing |
| HR user asks compensation question | Answer only from approved HR sources |
| User asks broad “summarize everything” question | Scope narrowed or refused |
| Source document is removed | No stale answer after sync/cache refresh |
| Source URL is returned | User can open it |
The last test is simple and useful: if the user cannot open the returned source, the system is not done.
Developer checklist
Before connecting an external source to Copilot, I would want clear answers to these questions:
- What is the source system?
- Who owns it?
- Which data categories are exposed?
- How is user identity passed to the backend?
- How are roles and groups resolved?
- Where is ACL filtering implemented?
- Are deny rules supported?
- Are sensitive topics blocked before retrieval?
- Are sources returned with the answer?
- Can the user open every returned source?
- What gets logged?
- How long are permissions cached?
- Who reviews access after org changes?
- What happens when the connector fails?
If that checklist feels annoying, good. It is cheaper than explaining later why Copilot answered from a document the user should never have seen.
My take
Security trimming is not a nice-to-have around Copilot. It is the difference between a useful assistant and a very fast permission leak.
For native Microsoft 365 content, start with tenant hygiene. For Graph connectors, map ACLs properly. For custom agents, make the backend enforce identity, permissions, topic rules and source visibility.
Do not rely on prompts as policy.
Prompts can guide behavior. They cannot replace access control.