Stop storing secrets: call an authenticated Power Automate flow from a Dataverse plug-in
A complete, illustrated walkthrough of Power Platform managed identity — so your plug-in can reach Azure resources and secured flows using a credential it never has to store, protect, or rotate.
The problem — and the idea behind the fix
A Dataverse plug-in often needs to reach something protected: a secret in Azure Key Vault, your own Web API, or a secured Power Automate flow. To do that, it must prove who it is.
The traditional answer was to hand the plug-in a secret — a client secret or password — and store it somewhere (in config, a secure step, or code). That “works,” but a stored secret is a permanent liability:
- It can leak. Anyone who reads the config, the code, or a log can copy it and impersonate your plug-in.
- It expires. Secrets have an expiry date; when it passes, your integration breaks until someone rotates it.
- It needs babysitting. Rotation, storage, and access control all become ongoing work.
- It widens your attack surface. Every stored secret is one more thing an attacker can target.
Power Platform managed identity removes the stored secret completely. Instead of carrying a credential, your plug-in asks Microsoft Entra ID for a token, and Entra ID vouches for the plug-in’s identity. The resource trusts Entra ID, so it trusts the token. No secret ever lives in your code or configuration.
What you gain
- Reach Azure resources straight from a plug-in. A Dataverse plug-in can call Azure Key Vault, your own APIs, or Power Automate directly — authenticated — without holding any credential.
- Stronger security by default. Nothing to leak, so a whole class of breaches disappears.
- Less operational toil. No secret to rotate or re-deploy when it expires.
- Seamless, standards-based auth. It uses Entra ID workload identity federation under the hood — the same mechanism that lets GitHub Actions or Kubernetes talk to Azure without secrets.
The example we’ll build: a Dataverse Custom API plug-in that takes a JSON body and the name of an environment variable (which holds a flow URL), gets a token via managed identity, and calls an OAuth-secured “When a HTTP request is received” flow — authenticated, with no secret anywhere.
How it fits together
Three systems must agree. Once they do, the whole chain runs automatically each time the plug-in fires.
Prerequisites
- An Azure subscription where you can create app registrations and federated credentials.
- Visual Studio to build a .NET Framework class library.
- The Plugin Registration Tool (PRT) and XrmToolBox (with the Plugin Identity Manager tool).
- SignTool.exe — we’ll check for it and install it if needed (step 2.2).
- Permission to grant admin consent in Entra ID (or an admin who can).
- A Power Automate environment.
dv-plugin-flow-caller, publisher prefix dtc, assembly/namespace ManagedIdentityPlugin, and Custom API dtc_CallSecuredFlow. Use your own names — just keep them consistent.PART 1 · 1.1Create the Entra ID app registration
Do this. Azure portal → Microsoft Entra ID → App registrations → New registration. Name it, keep it single-tenant, register. From the Overview page, copy the Application (client) ID and Directory (tenant) ID.
PART 1 · 1.2Create the code-signing certificate
Every managed-identity plug-in must be signed. The signing certificate is the plug-in’s fingerprint, and its hash is what the federated credential will trust.
1.3.6.1.5.5.7.3.3), which fixes that.# Folder to keep exported cert files $certDir = "C:\Certs" $params = @{ Type = 'Custom' Subject = 'E=admin@yourcompany.com,CN=YourCompany' # labels - change to your org TextExtension = @( '2.5.29.37={text}1.3.6.1.5.5.7.3.3,1.3.6.1.5.5.7.3.4', # 3.3 = Code Signing '2.5.29.17={text}email=admin@yourcompany.com') KeyAlgorithm = 'RSA' KeyLength = 2048 KeyExportPolicy = 'Exportable' CertStoreLocation = 'Cert:\CurrentUser\My' } $cert = New-SelfSignedCertificate @params Write-Host "Thumbprint:" $cert.Thumbprint # Public cert (.cer) + its SHA-256 hash (you need the hash in step 1.3) Export-Certificate -Cert $cert -FilePath "$certDir\MyCert.cer" | Out-Null (Get-FileHash "$certDir\MyCert.cer" -Algorithm SHA256).Hash.ToLower() # Private key (.pfx) - set YOUR password here $pwd = ConvertTo-SecureString "ChooseAStrongPassword" -Force -AsPlainText Export-PfxCertificate -Cert $cert -FilePath "$certDir\MyCert.pfx" -Password $pwd | Out-Null
What you can change after creation, and what to leave alone
- Change freely (labels only): the
Subject— e.g.CN=YourCompanyand the email. These are just display names and don’t need to be real or match anything in Azure. Set them to your company for clarity. - Leave as-is: the OID usages (
3.3Code Signing),RSA, and key length — these make it actually able to sign.
Where the password lives, and reusing it later
The certificate password is whatever you type in the Export-PfxCertificate -Password line — it is not generated for you. Two things to know:
- Save it somewhere safe — a password manager or Azure Key Vault. You cannot recover it from the
.pfxif you forget it. - You reuse the same password whenever you sign from the
.pfxfile again (for example on a build server or another machine). On the machine where you created the cert, you can sign from the certificate store with no password at all (shown in step 2.3).
.../i/{issuer}/s/{certificateSubject}) instead of the self-signed hash format.Certificate validity — and what happens at expiry
A self-signed certificate like this is valid for about 1 year. The signing command (next part) includes a timestamp, and that changes what expiry means:
| Situation | Works after the certificate expires? |
|---|---|
| An already-signed DLL, signed with a timestamp | Yes — keeps working, no action needed |
| An already-signed DLL, signed without a timestamp | No — becomes untrusted when the cert expires |
| Signing a brand-new build after the cert expires | No — renew the cert + update the federated credential hash |
Why we add the timestamp URL when signing
http://timestamp.digicert.comIt is a free, trusted timestamp service. When you sign, SignTool asks it for the official date and stamps it onto the signature, so the rule becomes “valid as long as the cert was valid when you signed.” That means the signature survives the certificate’s expiry. It’s like a notary dating a document: years later, even if your ID has expired, the notarised date proves you signed it while it was valid. Internet is needed only at the moment of signing; the date is then baked into the file.PART 1 · 1.3Configure the federated identity credential (FIC)
This is the secret-free trust rule that lets Entra ID issue a token to your app when your signed plug-in asks.
Do this. App registration → Certificates & secrets → Federated credentials tab → Add credential.
Choose scenario Other issuer and fill in:
https://login.microsoftonline.com/{tenantId}/v2.0; Type = Explicit subject identifier; Value = the subject string; Audience = api://AzureADTokenExchange.The subject for a self-signed cert is:
/eid1/c/pub/t/{encodedTenantId}/a/qzXoWDkuqUa3l6zM5mM0Rw/n/plugin/e/{environmentId}/h/{hash}{hash}— the SHA-256 of your.cerfrom step 1.2 (lowercase, no spaces).{environmentId}— Power Platform Admin Center → your environment → Environment ID.{encodedTenantId}— tenant GUID as Base64URL:
$g = [System.Guid]"00000000-0000-0000-0000-000000000000" [System.Convert]::ToBase64String($g.ToByteArray()).TrimEnd('=').Replace('+','-').Replace('/','_')
AADSTS700213The error stack shows the exact subject string Entra expected — copy it verbatim into the Value field. That is the fastest way to get the encoding right.PART 1 · 1.4Add the aud claim key step
Do this. App registration → Token configuration → Add optional claim → token type Access → select aud.
aud optional claim performs audience validation and emits the resource’s client ID. Adding it is what makes the flow accept the token.aud, iss, appid). If the audience claim isn’t present/aligned, the flow rejects the call with MisMatchingOAuthClaims (a 403). Adding the aud claim here is the fix for that error.PART 1 · 1.5Grant the Power Automate permission
This is the permission the flow trigger needs so it will accept your token. Do this. App registration → API permissions → Add a permission → Power Automate.
Choose the permission User — “Access Microsoft Flow as signed in user”, then click Grant admin consent.
PART 1 · 1.6Expose an API (set the audience)
Do this. App registration → Expose an API → set the Application ID URI (accept the default api://<client-id>).
api://<client-id>. This is the audience your plug-in will request a token for.api://<client-id>/.default. You do not need to add any custom scope under “Scopes defined by this API” for this flow.PART 2 · 2.1The Custom API plug-in
This plug-in reads the flow URL from an environment variable (named by the caller), acquires a token via managed identity, and POSTs the body to the flow. No secret, no tenant/client IDs in code.
using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Text; namespace ManagedIdentityPlugin { public class CallSecuredFlowPlugin : IPlugin { // Must match the Custom API parameter Unique Names exactly const string IN_FLOW_ENV = "FlowEnvVariableName"; const string IN_BODY = "Body"; const string IN_SCOPE = "Scope"; const string OUT_RESP = "Response"; const string OUT_STATUS = "StatusCode"; static readonly ConcurrentDictionary<string, string> EnvCache = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase); public void Execute(IServiceProvider sp) { var ctx = (IPluginExecutionContext)sp.GetService(typeof(IPluginExecutionContext)); var trace = (ITracingService)sp.GetService(typeof(ITracingService)); var factory = (IOrganizationServiceFactory)sp.GetService(typeof(IOrganizationServiceFactory)); var service = factory.CreateOrganizationService(ctx.UserId); try { var envName = GetInput(ctx, IN_FLOW_ENV); var body = GetInput(ctx, IN_BODY); var scope = GetInput(ctx, IN_SCOPE); if (string.IsNullOrWhiteSpace(envName) || string.IsNullOrWhiteSpace(body) || string.IsNullOrWhiteSpace(scope)) throw new InvalidPluginExecutionException("FlowEnvVariableName, Body and Scope are required."); // 1) Resolve the flow URL from the named environment variable var flowUrl = GetEnvVar(service, envName); if (string.IsNullOrWhiteSpace(flowUrl)) throw new InvalidPluginExecutionException("Environment variable '" + envName + "' not found or empty."); // 2) Acquire a token via managed identity - NO secret anywhere var mi = (IManagedIdentityService)sp.GetService(typeof(IManagedIdentityService)); if (mi == null) throw new InvalidPluginExecutionException("IManagedIdentityService unavailable - is a managed identity record attached?"); var token = mi.AcquireToken(new List<string> { scope }); trace.Trace("Token acquired, len=" + (token?.Length ?? 0)); // 3) POST the body to the flow with the bearer token int status; var resp = CallFlow(flowUrl, token, body, out status); // 4) Return result to the caller ctx.OutputParameters[OUT_RESP] = resp ?? ""; ctx.OutputParameters[OUT_STATUS] = status; } catch (InvalidPluginExecutionException) { throw; } catch (Exception ex) { trace.Trace("FAIL: " + ex); throw new InvalidPluginExecutionException("Call secured flow failed: " + ex.Message); } } static string CallFlow(string url, string token, string json, out int status) { using (var http = new HttpClient()) { http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); var content = new StringContent(json, Encoding.UTF8, "application/json"); var r = http.PostAsync(url, content).GetAwaiter().GetResult(); status = (int)r.StatusCode; return r.Content.ReadAsStringAsync().GetAwaiter().GetResult(); } } static string GetInput(IPluginExecutionContext ctx, string key) => (ctx.InputParameters != null && ctx.InputParameters.Contains(key) && ctx.InputParameters[key] != null) ? ctx.InputParameters[key].ToString() : null; static string GetEnvVar(IOrganizationService svc, string schema) { if (EnvCache.TryGetValue(schema, out var cached)) return cached; var defs = svc.RetrieveMultiple(new QueryExpression("environmentvariabledefinition") { ColumnSet = new ColumnSet("environmentvariabledefinitionid", "defaultvalue"), Criteria = { Conditions = { new ConditionExpression("schemaname", ConditionOperator.Equal, schema) } }, TopCount = 1 }); if (defs.Entities.Count == 0) { EnvCache[schema] = ""; return ""; } var def = defs.Entities[0]; var dflt = def.GetAttributeValue<string>("defaultvalue") ?? ""; var vals = svc.RetrieveMultiple(new QueryExpression("environmentvariablevalue") { ColumnSet = new ColumnSet("value"), Criteria = { Conditions = { new ConditionExpression("environmentvariabledefinitionid", ConditionOperator.Equal, def.Id) } }, TopCount = 1 }); var cur = vals.Entities.Count > 0 ? (vals.Entities[0].GetAttributeValue<string>("value") ?? "") : ""; var final = !string.IsNullOrWhiteSpace(cur) ? cur : dflt; EnvCache[schema] = final ?? ""; return EnvCache[schema]; } } }
.snk) so Dataverse can load the assembly. The certificate signing in step 2.3 is additional. You need both.PART 2 · 2.2Check for SignTool — and install it if missing
SignTool.exe is the utility that applies your certificate to the DLL. It often isn’t on PATH, so first check whether it already exists:
Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits", "C:\Program Files\Microsoft Visual Studio", "C:\Program Files (x86)\Microsoft SDKs" ` -Filter signtool.exe -Recurse -ErrorAction SilentlyContinue | Select-Object FullName
If it returns a path — you already have it. Copy the path and use it directly (you don’t need to install anything). A common location is C:\Program Files (x86)\Microsoft SDKs\ClickOnce\SignTool\signtool.exe.
If it returns nothing — install it one of these ways:
- winget (quickest):
winget install --id Microsoft.WindowsSDK.SigningTools— installs just the signing tools, small download. - Windows SDK installer: download the Windows SDK, and on the components screen tick only “Windows SDK Signing Tools for Desktop Apps.”
- Already have Visual Studio? Open the Developer Command Prompt for VS from the Start menu — it knows where
signtoollives, so you can call it by name there. (Make sure the .NET desktop development or Desktop development with C++ workload is installed.)
PART 2 · 2.3Sign the assembly
Build in Visual Studio first (this applies the strong name). Then sign the DLL. The most reliable way is from the certificate store — no file path, no password:
$signtool = "C:\Program Files (x86)\Microsoft SDKs\ClickOnce\SignTool\signtool.exe" $dllPath = "C:\Projects\ManagedIdentityPlugin\bin\Debug\ManagedIdentityPlugin.dll" # Auto-pick the CODE SIGNING cert (avoids the email-only one) $thumbprint = (Get-ChildItem Cert:\CurrentUser\My | Where-Object { $_.Subject -eq "E=admin@yourcompany.com, CN=YourCompany" -and $_.EnhancedKeyUsageList.FriendlyName -contains "Code Signing" }).Thumbprint & $signtool sign /sha1 $thumbprint /t http://timestamp.digicert.com /fd SHA256 $dllPath
What each switch means
| Part | Meaning |
|---|---|
/sha1 $thumbprint | Use the cert in the store with this thumbprint (instead of a .pfx file) |
/t http://timestamp.digicert.com | Add a trusted timestamp so the signature outlives the cert’s expiry |
/fd SHA256 | The hashing algorithm for the signature |
$dllPath | The DLL being signed |
Or, using the .pfx file and its password (e.g. on a build server):
& $signtool sign /f "C:\Certs\MyCert.pfx" /p "ChooseAStrongPassword" /t http://timestamp.digicert.com /fd SHA256 $dllPath
Or as a complete, self-contained script with every path defined up front (handy to copy and run on its own):
# --- Paths and settings (edit if names differ) --- $signtool = "C:\Program Files (x86)\Microsoft SDKs\ClickOnce\SignTool\signtool.exe" $pfxPath = "C:\Certs\MyCert.pfx" $dllPath = "C:\Projects\ManagedIdentityPlugin\bin\Debug\ManagedIdentityPlugin.dll" $password = "ChooseAStrongPassword" # --- Sign the DLL --- & $signtool sign /f $pfxPath /p $password /t http://timestamp.digicert.com /fd SHA256 $dllPath
PART 3 · 3.1Create a solution
Do this. make.powerapps.com → Solutions → New solution, with a publisher that has your prefix. The solution holds the flow, environment variable, Custom API, and parameters together — and is how you move everything to production later.
PART 3 · 3.2Create the environment variable (flow URL)
Do this. In the solution → New → More → Environment variable. Data type Text. You’ll set the Current Value to the flow’s URL after the flow exists (step 3.3).
dtc_OrderFlow), Data type Text, with the flow’s HTTP POST URL in Current Value.PART 3 · 3.3Create and secure the flow
Do this. Create a flow with the When a HTTP request is received trigger, set “Who can trigger the flow?” to Any user in my tenant, method POST, then add an action. Save and copy the HTTP POST URL into the environment variable from step 3.2.
aud claim from step 1.4, this accepts your managed-identity token.aud claim added (step 1.4), the Power Automate permission granted (step 1.5), and a matching Scope.PART 3 · 3.4Register the signed assembly (PRT)
Do this. Open the Plugin Registration Tool, connect, and Register New Assembly — select the signed DLL. Note the plug-in type, e.g. ManagedIdentityPlugin.CallSecuredFlowPlugin.
ManagedIdentityPlugin). Left side: the Custom API with Plugin Type bound to the registered plug-in. Do not register an SDK step — the Custom API wires it.PART 3 · 3.5Create & link the managed identity (XrmToolBox)
The Plugin Identity Manager tool both creates the managed identity record and links it to your assembly in one click.
AcquireToken, Dataverse knows which identity to ask for. The assembly must be signed first, or the tool refuses to link it.PART 3 · 3.6Create the Custom API
Do this. In the solution → New → Custom API: Binding Type Global, Is Function No, and set Plugin Type to your registered plug-in (from the lookup). See the left panel of the screenshot in step 3.4.
| Field | Value |
|---|---|
| Unique Name | dtc_CallSecuredFlow |
| Binding Type | Global |
| Is Function | No (it’s an Action — takes a body, has side effects) |
| Allowed Custom Processing Step Type | None |
| Plugin Type | ManagedIdentityPlugin.CallSecuredFlowPlugin |
PART 3 · 3.7Create the request parameters
Add three request parameters, each with Custom API = your API. The Unique Names must match the code exactly.
| Unique Name | Type | Optional? | Carries |
|---|---|---|---|
FlowEnvVariableName | String | No | Schema name of the env var holding the flow URL |
Body | String | No | JSON payload to send to the flow |
Scope | String | No | Token audience — api://<client-id>/.default |
PART 3 · 3.8Create the response parameters
| Unique Name | Type | Returns |
|---|---|---|
Response | String | The flow’s response body |
StatusCode | Integer | HTTP status code from the flow |
Publish all customizations.
PART 4 · 4.1Test it
Run this in the browser console while a model-driven app / maker portal is open on your environment:
(function () { var api = "dtc_CallSecuredFlow"; // your Custom API unique name var env = "dtc_OrderFlow"; // env var schema name (flow URL) var scope = "api://<your-client-id>/.default"; // the audience you exposed var payload = { FlowEnvVariableName: env, Body: JSON.stringify({ orderId: 123, note: "hello from console" }), // Body is a STRING of JSON Scope: scope }; fetch("/api/data/v9.2/" + api, { method: "POST", headers: { "Content-Type":"application/json", "OData-Version":"4.0", "Accept":"application/json" }, body: JSON.stringify(payload) }).then(async r => { var text = await r.text(); console.log("Custom API HTTP status:", r.status); if (!r.ok) { console.error("Custom API error:", text); return; } var d = text ? JSON.parse(text) : {}; console.log("Flow StatusCode:", d.StatusCode); console.log("Flow Response:", d.Response); }).catch(e => console.error(e)); })();
| Number | Means | If it errors |
|---|---|---|
| Custom API HTTP status | Did Dataverse run your plug-in? | 400/500 → problem in the plug-in / setup (read the text) |
| Flow StatusCode | What the flow returned | 401/403 → token acquired but flow rejected it (claims/audience) |
Success = Custom API 200 and Flow StatusCode 200/202. Cross-check the Plug-in Trace Log and the flow’s Run history.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
No certificates were found... | Cert is Secure Email, not Code Signing | Recreate with usage 3.3 (step 1.2); update the FIC hash |
signtool.exe not found | Not on PATH / not installed | Search; install Signing Tools or use Developer Command Prompt (step 2.2) |
AADSTS500011: resource principal not found | Audience not exposed/known for your app | Expose an API (step 1.6) and use a matching scope |
MisMatchingOAuthClaims (403) | Trigger’s claims don’t match the token | Add the aud claim (1.4) + Power Automate permission (1.5); match the scope |
AADSTS700213 | FIC subject/hash mismatch | Copy the expected subject from the error into the FIC |
IManagedIdentityService unavailable | Managed identity record not attached | Re-run Plugin Identity Manager “Create and Link” (3.5) |
| Worked, broke after rebuild | New build lost its signature | Re-sign with the same cert before updating the assembly |
Going to production
The certificate, signed plug-in, and app registration are reused. Anything tied to the environment ID is per-environment and must be redone in production.
| Thing | Carries over? | Action in prod |
|---|---|---|
| Signed DLL / Custom API / flow | Yes | Deploy via your managed solution |
| Certificate & app registration | Yes | Reuse — nothing to do |
| Power Automate permission & consent | Yes | Same app — nothing to do |
| Federated credential (FIC) | No | Add a new FIC with the prod environment ID |
| Managed identity record + link | No | Re-run Plugin Identity Manager in prod |
| Environment variable value | No | Set the prod flow’s URL |
.../i/{issuer}/s/{certificateSubject}) instead of the self-signed hash.UNDER THE HOODWhat happens when the plug-in asks for a token
Everything above is the one-time setup. This section explains what happens at runtime — the actual sequence each time your plug-in needs a token. No secret is stored or sent at any point; the trust is proven by the signed plug-in instead.
Step by step
- An event fires the plug-in. The Dataverse sandbox loads your signed assembly and runs
Execute. - Your code asks for the service with
GetService(typeof(IManagedIdentityService)). - Your code calls
AcquireToken(scopes), passing only the scope of the resource you want — no credentials. - Dataverse decides which identity to use. It reads the managed identity record attached to this assembly to get the app’s client ID + tenant (with
credentialsource = 2meaning “managed, not a stored secret”). - The platform verifies the plug-in’s signature — confirming the assembly is genuinely signed by the certificate and untampered.
- The platform builds a signed assertion describing the workload: the issuer (your tenant) and the subject (tenant + environment + plug-in + the certificate’s hash).
- It calls Entra ID’s token endpoint using workload identity federation — sending that assertion as the proof, still with no secret.
- Entra ID matches the assertion to the federated credential — checking the issuer and that the subject (cert hash + environment) matches. A mismatch here is
AADSTS700213. - Entra ID checks the app’s permission for the requested resource/scope. A failure here is the
AADSTS500011family. - Entra ID issues a short-lived access token (scoped to the resource, typically ~1 hour) and returns it.
AcquireTokenreturns the token to your code — never a credential.- Your plug-in calls the resource with
Authorization: Bearer <token>over HTTPS. - The resource validates the token (signature + claims) and responds. A claims mismatch at a secured flow is
MisMatchingOAuthClaims.
Two key terms: assertion and certHash
certHash is the SHA-256 fingerprint of your signing certificate (from Get-FileHash MyCert.cer -Algorithm SHA256). The same certificate always produces the same hash, so it’s a compact, unique way to refer to “this exact certificate.” It appears in two places that must agree: it’s the {hash} in the federated credential’s subject, and it identifies the certificate that signed your plug-in. (Note: this is not the SHA-1 “Thumbprint” shown in the Windows cert dialog.)
assertion is the short-lived, signed token (a JWT) the Power Platform runtime creates to vouch for your plug-in. In a traditional flow the app would send a client secret as proof; here it sends this signed assertion instead — which is what makes the whole thing secret-free. Inside it: the issuer, the subject (which contains the certHash and environment), a signature, and an expiry.
So the two are nested — the certHash is carried inside the assertion’s subject — and Entra ID’s check is simply a comparison:
| Inside the signed assertion (what the plug-in sends) | Inside the federated credential (what Entra ID trusts) |
|---|---|
| Issuer = your tenant | Issuer = your tenant |
| Subject → environment ID | Subject → environment ID |
| Subject → certificate hash | Subject → certificate hash |
FAQ
Do I change the email/password in the certificate script?
The email/subject are labels — keep or rename for your company; they need not be real. The password is whatever you choose in Export-PfxCertificate; save it securely and reuse it whenever you sign from the .pfx again.
Where do I get the {hash} for the FIC?
From Get-FileHash MyCert.cer -Algorithm SHA256 (lowercase, no spaces). Don’t use the cert dialog “Thumbprint” — that’s SHA-1.
What permission do I actually need?
Power Automate → “Access Microsoft Flow as signed in user” with admin consent (step 1.5). That is the confirmed-working permission for the “Any user in my tenant” trigger with a managed-identity token.
Will my plug-in keep working after the cert expires?
Yes for an already-signed DLL signed with the timestamp. You only renew the cert (and update the FIC hash) when signing a brand-new build after expiry.
Can one certificate sign many plug-ins?
Yes, unlimited. Only the FIC is added per plug-in, and it references the same cert hash.
Do I re-sign after every build?
Yes — rebuilding removes the signature. Build → re-sign → update the assembly. A post-build event automates it.
Self-signed or CA certificate?
Self-signed is fine for dev/test. For production, buy a Code Signing certificate from a trusted CA (Microsoft’s recommendation).
For more information — Microsoft Docs
The official documentation behind everything in this guide: