Power Platform · Dataverse · Azure

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.

No client secret No stored credentials Real authentication Custom API Step-by-step + screenshots

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.

The mental modelA stored secret is like leaving a house key under the doormat — anyone who finds it gets in. Managed identity is like showing an ID badge that the building itself issues and verifies on the spot. The badge can’t be copied and stolen the way a key can, and it’s renewed automatically behind the scenes.

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.

Dataverse plug-in signed · AcquireToken Entra ID app reg + federated cred Power Automate OAuth-secured flow Signing certificate the plug-in’s fingerprint 1. request token 2. token → call flow trust anchored to cert
The trust chain: the certificate proves the plug-in’s identity → Entra ID issues a token → the flow validates that token.

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.
Naming in this guideThe running example uses an app registration named 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 IDApp registrationsNew registration. Name it, keep it single-tenant, register. From the Overview page, copy the Application (client) ID and Directory (tenant) ID.

WhyThis app registration is the identity your plug-in presents. Every later step — the certificate trust, the Dataverse record, the token the flow validates — points back to it. Keep both IDs handy.

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.

It must be a Code Signing certificateMicrosoft’s sample snippet makes a Secure Email certificate, which cannot sign code — SignTool rejects it with “No certificates were found that met all the given criteria.” The script below adds the Code Signing usage (1.3.6.1.5.5.7.3.3), which fixes that.
PowerShell — create (dev/test)
# 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=YourCompany and 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.3 Code 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 .pfx if you forget it.
  • You reuse the same password whenever you sign from the .pfx file 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).
Dev/test vs. production — Microsoft’s guidanceA self-signed certificate is fine only for development and testing. For production, Microsoft recommends a certificate from a trusted Certificate Authority — you buy a Code Signing certificate from a CA such as DigiCert, Sectigo, or GlobalSign. With a CA certificate, the federated-credential subject uses the trusted-issuer format (.../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:

SituationWorks after the certificate expires?
An already-signed DLL, signed with a timestampYes — keeps working, no action needed
An already-signed DLL, signed without a timestampNo — becomes untrusted when the cert expires
Signing a brand-new build after the cert expiresNo — renew the cert + update the federated credential hash

Why we add the timestamp URL when signing

About 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 & secretsFederated credentials tab → Add credential.

Certificates and secrets, Federated credentials tab, Add credential
Certificates & secrets → Federated credentials → Add credential. After saving you’ll see your credential listed (here named pluginflowfed).

Choose scenario Other issuer and fill in:

Edit a credential - issuer, type, subject value, audience
Issuer = 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:

Subject identifier
/eid1/c/pub/t/{encodedTenantId}/a/qzXoWDkuqUa3l6zM5mM0Rw/n/plugin/e/{environmentId}/h/{hash}
  • {hash} — the SHA-256 of your .cer from step 1.2 (lowercase, no spaces).
  • {environmentId} — Power Platform Admin Center → your environment → Environment ID.
  • {encodedTenantId} — tenant GUID as Base64URL:
PowerShell
$g = [System.Guid]"00000000-0000-0000-0000-000000000000"
[System.Convert]::ToBase64String($g.ToByteArray()).TrimEnd('=').Replace('+','-').Replace('/','_')
If you get 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 configurationAdd optional claim → token type Access → select aud.

Token configuration, optional claim aud added for Access token
The aud optional claim performs audience validation and emits the resource’s client ID. Adding it is what makes the flow accept the token.
Why this mattersWhen a token reaches the secured flow, the flow checks its claims (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 permissionsAdd a permissionPower Automate.

Request API permissions, Power Automate selected
API permissions → Add a permission → pick Power Automate from the API list.

Choose the permission User — “Access Microsoft Flow as signed in user”, then click Grant admin consent.

Power Automate User delegated permission with admin consent granted
The confirmed-working permission: Power Automate → User (“Access Microsoft Flow as signed in user”), with admin consent granted (green checks).
What permission do you actually need?Exactly the one above — Power Automate “Access Microsoft Flow as signed in user” with admin consent. Admin consent is required because this permission needs an administrator to approve it for the whole tenant. With this granted, your managed-identity token is accepted by the “Any user in my tenant” flow trigger.

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>).

Expose an API, Application ID URI set to api://client-id
Expose an API → the Application ID URI api://<client-id>. This is the audience your plug-in will request a token for.
WhyYour plug-in asks for a token whose audience matches this URI. The Scope you pass to the Custom API will be 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.

C#
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];
        }
    }
}
Strong name (.snk) is still requiredThis is separate from certificate signing. In Project Properties → Signing, keep “Sign the assembly” ticked (the .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:

PowerShell — find it
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 signtool lives, 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:

PowerShell — sign from store
$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

PartMeaning
/sha1 $thumbprintUse the cert in the store with this thumbprint (instead of a .pfx file)
/t http://timestamp.digicert.comAdd a trusted timestamp so the signature outlives the cert’s expiry
/fd SHA256The hashing algorithm for the signature
$dllPathThe DLL being signed

Or, using the .pfx file and its password (e.g. on a build server):

PowerShell — sign from .pfx
& $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):

PowerShell — sign from .pfx (full script)
# --- 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
You must re-sign after EVERY buildEach rebuild creates a fresh DLL that has the strong name but not the certificate signature. Always: build → re-sign → update the assembly in Dataverse. You reuse the same certificate (no FIC change). Tip: add the sign line as a Visual Studio post-build event so it happens automatically.
One certificate signs unlimited plug-insYou never need a separate cert per plug-in. The only per-plug-in piece is the federated credential in Entra ID, and it still points at this same cert’s hash.

PART 3 · 3.1Create a solution

Do this. make.powerapps.com → SolutionsNew 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).

Environment variable OrderFlow, Text type, current value set to flow URL
An environment variable (example schema name dtc_OrderFlow), Data type Text, with the flow’s HTTP POST URL in Current Value.
WhyThe plug-in reads the flow URL from this variable at runtime by its schema name. Keeping the URL out of code means you can change flows or move environments without recompiling.

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.

HTTP trigger set to Any user in my tenant, POST method
Trigger secured with Any user in my tenant (keeps it authenticated), method POST. Together with the aud claim from step 1.4, this accepts your managed-identity token.
Keep it “Any user in my tenant” — not “Anyone”“Anyone” would remove authentication entirely. The combination that works with real authentication is: trigger = Any user in my tenant, the 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.

Plugin Registration Tool register new assembly and the Custom API with plugin type bound
Right side: PRT → Register New Assembly (the signed 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.
No PAC/PRT “Custom API plugin” neededOnce the assembly is registered, you don’t create a Custom API plug-in from the tool. Go to Dataverse, create the Custom API, and select the registered assembly from the lookup (step 3.6).

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.

XrmToolBox Plugin Identity Manager - select assembly, Link to New Identity, Create and Link
1 open Plugin Identity Manager → 2 select your assembly → 3 Link to New Identity → 4 fill ApplicationId (your Client ID) and TenantId, leave Credential Source = IsManaged and Subject Scope = EnvironmentScope, then Create and Link.
Why — and why it’s two things at onceDataverse and Entra ID are separate systems. The record is Dataverse’s local pointer to your Azure identity (create); linking attaches it to your specific assembly (link). So when the plug-in calls 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.

FieldValue
Unique Namedtc_CallSecuredFlow
Binding TypeGlobal
Is FunctionNo (it’s an Action — takes a body, has side effects)
Allowed Custom Processing Step TypeNone
Plugin TypeManagedIdentityPlugin.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.

Custom API Request Parameters: Body, FlowEnvVariableName, Scope
The three request parameters in the solution: Body, FlowEnvVariableName, Scope — all type String.
Body request parameter detail - String, not optional, linked to Custom API
Each parameter links to the Custom API; here Body (Type String, Is Optional No, “The JSON payload to send to the flow”).
Unique NameTypeOptional?Carries
FlowEnvVariableNameStringNoSchema name of the env var holding the flow URL
BodyStringNoJSON payload to send to the flow
ScopeStringNoToken audience — api://<client-id>/.default

PART 3 · 3.8Create the response parameters

Response - Custom API Response Property, String
A Custom API Response Property — here Response (Type String, “The flow’s response body”). Add a second one, StatusCode (Integer).
Unique NameTypeReturns
ResponseStringThe flow’s response body
StatusCodeIntegerHTTP 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:

JavaScript
(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));
})();
NumberMeansIf it errors
Custom API HTTP statusDid Dataverse run your plug-in?400/500 → problem in the plug-in / setup (read the text)
Flow StatusCodeWhat the flow returned401/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

SymptomCauseFix
No certificates were found...Cert is Secure Email, not Code SigningRecreate with usage 3.3 (step 1.2); update the FIC hash
signtool.exe not foundNot on PATH / not installedSearch; install Signing Tools or use Developer Command Prompt (step 2.2)
AADSTS500011: resource principal not foundAudience not exposed/known for your appExpose an API (step 1.6) and use a matching scope
MisMatchingOAuthClaims (403)Trigger’s claims don’t match the tokenAdd the aud claim (1.4) + Power Automate permission (1.5); match the scope
AADSTS700213FIC subject/hash mismatchCopy the expected subject from the error into the FIC
IManagedIdentityService unavailableManaged identity record not attachedRe-run Plugin Identity Manager “Create and Link” (3.5)
Worked, broke after rebuildNew build lost its signatureRe-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.

ThingCarries over?Action in prod
Signed DLL / Custom API / flowYesDeploy via your managed solution
Certificate & app registrationYesReuse — nothing to do
Power Automate permission & consentYesSame app — nothing to do
Federated credential (FIC)NoAdd a new FIC with the prod environment ID
Managed identity record + linkNoRe-run Plugin Identity Manager in prod
Environment variable valueNoSet the prod flow’s URL
Production certificateSwap the self-signed dev cert for a CA-issued Code Signing certificate (DigiCert, Sectigo, GlobalSign). The production FIC subject then uses the trusted-issuer format (.../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.

Runtime token flow The plug-in calls AcquireToken; the Power Platform runtime verifies the signature and builds a signed assertion; Entra ID matches it to the federated credential by certificate hash and environment; Entra ID issues a short-lived token; the plug-in calls the resource with it. Plug-in calls AcquireToken asks for a token — no secret sent Power Platform runtime verifies signature, builds assertion Entra ID matches the credential cert hash + environment must match Entra ID issues a token short-lived, resource-scoped Plug-in calls the resource Key Vault, API, or flow — with the token
Navy = Power Platform, teal = Microsoft Entra ID, coral = the target resource. The certificate is the shared anchor that ties the plug-in to the federated credential.

Step by step

  1. An event fires the plug-in. The Dataverse sandbox loads your signed assembly and runs Execute.
  2. Your code asks for the service with GetService(typeof(IManagedIdentityService)).
  3. Your code calls AcquireToken(scopes), passing only the scope of the resource you want — no credentials.
  4. 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 = 2 meaning “managed, not a stored secret”).
  5. The platform verifies the plug-in’s signature — confirming the assembly is genuinely signed by the certificate and untampered.
  6. The platform builds a signed assertion describing the workload: the issuer (your tenant) and the subject (tenant + environment + plug-in + the certificate’s hash).
  7. It calls Entra ID’s token endpoint using workload identity federation — sending that assertion as the proof, still with no secret.
  8. 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.
  9. Entra ID checks the app’s permission for the requested resource/scope. A failure here is the AADSTS500011 family.
  10. Entra ID issues a short-lived access token (scoped to the resource, typically ~1 hour) and returns it.
  11. AcquireToken returns the token to your code — never a credential.
  12. Your plug-in calls the resource with Authorization: Bearer <token> over HTTPS.
  13. 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 tenantIssuer = your tenant
Subject → environment IDSubject → environment ID
Subject → certificate hashSubject → certificate hash
The whole authentication in one lineEntra ID issues the token only if these match — i.e. the certificate that signed the plug-in is the same certificate registered in the federated credential, in the same environment. No password or private key ever crosses the wire; the match of a fingerprint inside a signed statement is the proof.

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: