Have you ever wanted to test a real OAuth flow against Business Central without spinning up a sandbox? I had that itch for a long time. Every time I built something that needed a token, a delegated call, a service to service integration, an Azure Function hitting the API, it felt like I needed a SaaS environment to try it properly. It turns out you do not. You can exercise the whole thing against a Business Central OnPrem container running on your own machine.
Configuring OAuth2 against BC OnPrem was never obvious to me, and Docker adds its own friction: the self signed certificate, the hosts file, and above all the audiences the service tier is willing to accept, the parts a plain on premises install tends to hand you more directly. This post is the end to end recipe to get there, with the real output and, more importantly, the real traps I hit along the way. Everything here was executed and validated against a BC v28.1 OnPrem container.
The idea that unlocks it: OAuth against Business Central works the same whether the server sits in Microsoft’s cloud or in a container on your laptop. What moves is who configures the trust. The App Registration you would build for SaaS has the same shape here. The only real difference is where the token gets validated: in BC online Microsoft checks it against its own resource, and in your container you decide which audiences the service tier accepts.
Think of Entra ID as the receptionist of a building who issues badges (tokens). In BC online, Microsoft already hired the receptionist and handed over the guest list, so you just register your app and ask for permissions. In your Docker container the building is yours, the receptionist exists (your Entra tenant), but nobody handed him the guest list yet. Configuring the server and ValidAudiences is, literally, handing him that list.
This is a lab, not production. Self signed certificates, an extended token lifetime and DisableTokenSigningCertificateValidation are development conveniences. More on that at the end.
TL;DR
- A Business Central OnPrem container plus your own Azure tenant gives you a full OAuth lab, no SaaS sandbox required (and no Business Central subscription needed).
BcContainerHelpercreates the Entra App Registration and wires the container for you.- The container ends up in a hybrid state: the service tier stays on
NavUserPassword, the web client does OpenID Connect single sign-on, and the web services accept Bearer tokens because ofValidAudiences. - On a free tenant you can validate three flows end to end: web client SSO, delegated OAuth and service to service against
api/v2.0. VS Code symbol download is the one piece that depends on the Business Central first party resource and can fail on a bare tenant, called out below. - On a tenant with a BC subscription you use the portable scope
https://api.businesscentral.dynamics.com/.default. On a free tenant that resource is disabled (AADSTS500014), so you use your own App ID URI as the resource. Both are covered below.
The mental model: SaaS vs Docker
It is essentially the same App Registration. What changes is who validates the token and where the trust lives.
| BC SaaS (online) | BC OnPrem on Docker | |
|---|---|---|
| Who validates the Bearer token | Microsoft’s service | Your container’s service tier (NST) |
| Accepted audience | https://api.businesscentral.dynamics.com |
Whatever you put in ValidAudiences (your app’s client id, the SaaS audience, your App ID URI) |
| Where you grant API permissions to a client | Microsoft Entra Applications page (in BC) | Same page, in your container |
| Who configures the trust | Microsoft | You (BcContainerHelper does ~90%) |
Once you internalize that the only new responsibility is “tell the NST which audiences to accept”, everything else is the same OAuth you already know.
Prerequisites
Three things bite before you write a single line, so let me be blunt about them.
1. Docker in Windows containers mode. Business Central images are Windows containers. On Docker Desktop, right click the tray icon and “Switch to Windows containers”. On Windows 10/11 Pro the isolation will be Hyper-V (that is automatic and correct on a client OS).
2. Windows PowerShell 5.1, not PowerShell 7. This one is subtle. BcContainerHelper 6.x advertises Core compatibility, but if you also have the old navcontainerhelper module lying around (mine was a 0.7.x copy in OneDrive, first in PSModulePath), it shadows the Bc* commands under pwsh 7 and they blow up with a #requires ... 'Desktop' error. Run the whole lab from powershell.exe (5.1) and pin the version so a second installed copy does not win:
# Windows PowerShell 5.1
Import-Module BcContainerHelper -RequiredVersion 6.1.11
(Get-Module BcContainerHelper).Version # 6.1.11
3. The Microsoft.Graph module and an Azure tenant. New-AadAppsForBc talks to Microsoft Graph. Pre install it so the helper does not pull the full module mid run:
Install-Module Microsoft.Graph -Scope CurrentUser -Force # 2.37.0 here
For the tenant: a free Azure tenant is enough. When you sign up for Azure with a Microsoft account you get a “Default Directory” with a *.onmicrosoft.com domain where you are the admin. That is where your App Registrations live. You do not need a Business Central subscription to follow most of this post.
Headless auth trap. If you script this on a machine without a real interactive console, Connect-MgGraph -UseDeviceCode prints the code and then dies with An error occurred when writing to a listener. The clean workaround is to reuse an az login session and hand the token to the helper:
$token = az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv
New-AadAppsForBc -accessToken $token ...
On a brand new tenant you may also need to provision the Azure CLI service principal once: az ad sp create --id 04b07795-8ddb-461a-bbee-02f9e1bf7b46.
Step 1: Create the App Registration
New-AadAppsForBc creates the SSO app (the “resource”) and, with -IncludeApiAccess, also a client app for service to service. The blessed, simplest call is:
$adProps = New-AadAppsForBc `
-appIdUri "api://$containerName" `
-publicWebBaseUrl "https://$containerName/BC" `
-IncludeApiAccess
$adProps.SsoAdAppId # interactive / delegated resource app
$adProps.ApiAdAppId # service to service client
$adProps.ApiAdAppKeyValue # its secret
That works on many tenants. On a strict, modern tenant (which a fresh free tenant tends to be) it does not, and the failure is worth knowing about.
Trap: the modern App ID URI policy. Newer tenants reject App ID URIs that are not “qualified”: you get InvalidUniqueTenantIdentifierAsPerAppPolicy, with the message that the URI must contain a verified domain, the tenant id or the app id. So api://bcaad is rejected. Worse, -IncludeApiAccess derives the client app’s URI by prefixing api. (it builds api://api.<...>), an unverified subdomain, so the client app cannot be created either. Net effect: -IncludeApiAccess is incompatible with that policy.
The fix is two part: use your verified domain for the App ID URI, and build the service to service side by hand (Step 8). So:
$appIdUri = "api://<yourtenant>.onmicrosoft.com/$containerName" # verified domain -> policy compliant
$adProps = New-AadAppsForBc -accessToken $token `
-appIdUri $appIdUri `
-publicWebBaseUrl "https://$containerName/BC" `
-SingleTenant -preAuthorizePowerShell -autoConsent
What you get back, either way, is an SSO app. Read it back and you will see it is correct for OpenID Connect: a “Web” platform with https reply URLs, a published user_impersonation scope, and (thanks to -preAuthorizePowerShell) the well known PowerShell client pre authorized so you can grab delegated tokens later.
signInAudience : AzureADMyOrg
redirectUris : https://bcaad/BC/SignIn, https://bcaad/BC # https, not http
scopes : user_impersonation
appId (SsoAdAppId) : <sso-app-id>
Why https matters. Entra only allows http reply URLs for localhost. New-AadAppsForBc builds the reply URLs from publicWebBaseUrl, so if you use a named host over http the web client sign in is rejected with AADSTS500117. That is why the container is created with -useSSL in the next step, so the reply URL is https://bcaad/BC/SignIn.
Step 2: Create the container
Now the container, wired to the SSO app. This step writes the hosts file and installs the self signed certificate into the host trust store, so it must run elevated.
# Windows PowerShell 5.1, elevated
New-BcContainer `
-accept_eula `
-containerName $containerName `
-artifactUrl (Get-BcArtifactUrl -type OnPrem -country w1) `
-auth AAD `
-credential (New-Object pscredential 'admin', (ConvertTo-SecureString '<StrongLabPwd!>' -AsPlainText -Force)) `
-authenticationEMail '<labuser>@<yourtenant>.onmicrosoft.com' `
-AadTenant $adProps.AadTenant `
-AadAppId $adProps.SsoAdAppId `
-AadAppIdUri $appIdUri `
-useSSL `
-installCertificateOnHost `
-updateHosts
A few minutes later (it downloads the artifact and the generic image and boots with Hyper-V isolation) you get:
Container bcaad successfully created
Web Client : https://bcaad/BC/
Dev. Server: https://bcaad ServerInstance: BC
Trap: cannot use WinRm. On some Windows 11 plus Docker Desktop setups, BcContainerHelper cannot open a PowerShell session into the container, so Get-BcContainerServerConfiguration and Invoke-ScriptInBcContainer fail. The container itself is fine. Read or change its configuration with plain docker exec instead. That is what I use below.
Step 3: The “aha”, what the helper actually configures
This is the part almost nobody spells out. Look at what -AadAppId produced. Read the service tier config straight from the container:
docker exec $containerName powershell -NoProfile -Command `
"Get-Content 'C:\Program Files\Microsoft Dynamics NAV\280\Service\CustomSettings.config'"
The relevant keys (real values, ids shortened):
ClientServicesCredentialType = NavUserPassword
ValidAudiences = <sso-app-id>;https://api.businesscentral.dynamics.com
DisableTokenSigningCertificateValidation = True
ExtendedSecurityTokenLifetime = 24
AppIdUri = api://<yourtenant>.onmicrosoft.com/bcaad
And the web server config (navsettings.json):
ClientServicesCredentialType = AccessControlService # the web client does OIDC
AadApplicationId = <sso-app-id>
AadAuthorityUri = https://login.microsoftonline.com/<tenant-id>
AadValidAudience = https://api.businesscentral.dynamics.com
Put the pieces together and you get the hybrid that gives this whole setup its personality:
- The service tier stays on
NavUserPassword, so the container username and password still work (fallback web client, old tools, scripts). - The web client does single sign on with Entra over OpenID Connect (
AadApplicationIdplusAadAuthorityUri). - The web services and APIs accept Bearer tokens because
ValidAudienceslists two audiences: your app’s client id andhttps://api.businesscentral.dynamics.com.
The community documented this years ago as a “trick” (NavUserPassword on the NST plus OAuth on web services so VS Code keeps working). Today it is not a trick, it is what BcContainerHelper does out of the box.
A version specific note. This hybrid is what BcContainerHelper 6.1.11 produces for a BC v28.1 container. If you wire a classic on premises server by hand, Microsoft’s OpenID Connect guide moves the service tier itself to AccessControlService; the helper instead leaves the NST on NavUserPassword and lets only the web services accept Bearer tokens through ValidAudiences. Both authenticate against Entra, they just draw the line in a different place. See Microsoft’s OpenID Connect setup for Business Central.
Step 4: Web client single sign on
Open https://bcaad/BC in a browser. You are redirected to Microsoft, you sign in, and you land in Business Central authenticated with Entra ID.
The mapping rule: the BC user’s Authentication Email must equal the UPN of the Entra account you sign in with. The container’s admin user (the one from -credential) gets the -authenticationEMail you passed.
Trap: “Sorry, we couldn’t sign you in”. The first time, I signed in with the wrong account (one whose Authentication Email is not mapped in BC) and got exactly that screen. Signing in with the mapped user worked immediately.
A related subtlety: a personal Microsoft account joins your directory as a guest (name_outlook.com#EXT#@yourtenant.onmicrosoft.com), which muddies the email mapping. The clean fix is to create a dedicated member user in your directory and use it for the lab:
az ad user create --display-name "BC Lab" `
--user-principal-name "bclab@<yourtenant>.onmicrosoft.com" `
--password '<pwd>' --force-change-password-next-sign-in false
Step 5: Develop from VS Code with Entra ID
Point the AL extension at the container with a launch.json that uses authentication: AAD:
{
"name": "BC Docker (Entra ID)",
"request": "launch",
"type": "al",
"environmentType": "OnPrem",
"server": "https://bcaad",
"serverInstance": "BC",
"authentication": "AAD",
"tenant": "default",
"primaryTenantDomain": "<yourtenant>.onmicrosoft.com"
}
AL: Download symbols and F5 trigger an interactive sign in. The AL tooling uses device code flow (so it needs no reply URL of its own), and the token it acquires carries the audience https://api.businesscentral.dynamics.com, which is why that audience is in ValidAudiences by default.
Honest caveat for free tenants. The AL extension asks for that api.businesscentral.dynamics.com audience, and that resource is disabled on a tenant without a Business Central subscription (the AADSTS500014 you will meet in Step 6), so Download symbols can fail there. This is the one piece of the lab that prefers a tenant with a BC subscription. The web client, the delegated flow and service to service all work on a free tenant using your own resource, as shown next.
Step 6: The free tenant move, your own resource
Here is the move a free tenant forces on you, and the part of this post that matters most if you have no Business Central subscription. It is also the prerequisite the two flows below depend on, so do it before them.
The moment you request a token for https://api.businesscentral.dynamics.com on a bare tenant, you get:
AADSTS500014: The service principal for resource 'https://api.businesscentral.dynamics.com'
is disabled.
The Business Central first party service principal exists in your tenant but is disabled, because no BC service is provisioned there. Entra will not issue tokens for it, so the portable, Microsoft documented scope is off the table. (Same reason VS Code symbol download struggles on a free tenant: the AL tooling asks for exactly that resource.)
The way out is the own resource pattern: make your SSO app the API and ask for tokens against it. The app already publishes user_impersonation and owns your App ID URI, so the only missing piece is the audience. A v1 token for your own resource carries aud = {appIdUri}, so hand the receptionist one more name on the guest list, your App ID URI, by adding it to ValidAudiences and restarting:
docker exec $containerName powershell -NoProfile -Command `
". 'C:\Program Files\Microsoft Dynamics NAV\280\Service\NavAdminTool.ps1';
Set-NAVServerConfiguration -ServerInstance BC -KeyName ValidAudiences `
-KeyValue '<sso-app-id>;https://api.businesscentral.dynamics.com;api://<yourtenant>.onmicrosoft.com/bcaad';
Set-NAVServerInstance -ServerInstance BC -Restart"
With your App ID URI in ValidAudiences, both flows below can ask for scope = {appIdUri}/.default and the service tier accepts the audience. On a tenant that does have a BC subscription you can skip this step and use the portable https://api.businesscentral.dynamics.com/.default instead.
Step 7: Delegated OAuth against api/v2.0
A delegated token is a token on behalf of a user. What matters is the resource you ask for, because that sets the aud claim the service tier checks against ValidAudiences.
Scope versus permission. The delegated permission your SSO app publishes is user_impersonation. The scope you actually request is {appIdUri}/.default, which bundles the delegated permissions already consented on that resource; the token then comes back with scp = user_impersonation. On a tenant with a BC subscription you would swap in the portable https://api.businesscentral.dynamics.com/.default and get the SaaS audience instead.
The simplest reliable way to get a delegated token in a lab is the well known PowerShell public client (1950a258-227b-4e31-a9cf-717495945fc2), which New-AadAppsForBc -preAuthorizePowerShell already authorized on your SSO app.
ROPC is a lab shortcut, not a pattern. The call below uses Resource Owner Password Credentials to keep the lab scriptable. Do not copy it to production: Microsoft recommends against ROPC, it breaks with MFA, and it does not work for the personal account that joined your tenant as a guest. That last point is exactly why the lab uses a dedicated member user (bclab@...). See the ROPC reference.
$body = @{
grant_type = 'password'
client_id = '1950a258-227b-4e31-a9cf-717495945fc2'
scope = "$appIdUri/.default" # or https://api.businesscentral.dynamics.com/.default
username = 'bclab@<yourtenant>.onmicrosoft.com'
password = '<pwd>'
}
$tok = Invoke-RestMethod -Method Post -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" -Body $body
Decode the token and you see who it is for:
aud = api://<yourtenant>.onmicrosoft.com/bcaad
upn = bclab@<yourtenant>.onmicrosoft.com
scp = user_impersonation
appid = 1950a258-227b-4e31-a9cf-717495945fc2
Call the API with it (use curl.exe -k for the self signed certificate, see the traps):
curl.exe -k -H "Authorization: Bearer $($tok.access_token)" https://bcaad:7048/BC/api/v2.0/companies
HTTP 200
{"value":[{"name":"CRONUS International Ltd.","systemVersion":"28.1.49838.49886", ...}]}
There it is: a delegated user token calling your Business Central Docker API and getting real data.
Step 8: Service to service (client credentials)
That was the user side. The app only side, service to service, needs one more piece of setup. You register a client app with a secret and an application permission, and then you register that same client inside BC.
Two things make the free tenant version specific. The client is built by hand with no App ID URI (the modern policy again, Trap 4). And because the SaaS resource is disabled, the application permission lives on your own SSO app: add an application app role API.ReadWrite.All to the SSO app and assign it to this client’s service principal. That assignment is the admin consent, and it is what puts roles = API.ReadWrite.All in the client’s token.
The token request is the usual client credentials call:
$body = @{
grant_type = 'client_credentials'
client_id = $s2sClientId
client_secret = $s2sSecret
scope = "$appIdUri/.default" # or https://api.businesscentral.dynamics.com/.default
}
$tok = Invoke-RestMethod -Method Post -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" -Body $body
The token is valid, but the first call fails on purpose, and this is the trap everyone hits once:
HTTP 401
{"error":{"code":"Authentication_InvalidCredentials","message":"The server has rejected the client credentials."}}
The token is fine (BC accepted the audience). What is missing is the registration of the client inside Business Central. In the web client, search for Microsoft Entra Applications, choose New, and fill in:
- Client ID: the client app’s Application (client) id.
- State: Enabled.
- User Permission Sets: assign what the app needs (for a lab,
D365 BUS FULL ACCESS). Applications cannot getSUPER.
Grant Consent on that page is optional if you already granted admin consent in Entra.
Run the same call again and you get your data:
HTTP 200
{"value":[{"name":"CRONUS International Ltd.", ...}]}
Both OAuth flows, delegated and service to service, now work against your Business Central Docker container, and on a free tenant they do it entirely against your own App ID URI instead of Microsoft’s resource. That is the whole point: a free Azure tenant plus Docker is enough, you just point the OAuth at your own resource.
The traps, in one table
| Symptom | Cause | Fix |
|---|---|---|
#requires ... 'Desktop' on Bc* commands |
A legacy navcontainerhelper (Desktop only) shadows the commands under pwsh 7 |
Run in Windows PowerShell 5.1; pin -RequiredVersion 6.1.11 |
Connect-MgGraph dies: “writing to a listener” |
Interactive device code cannot run in a headless/redirected shell | Reuse az token: New-AadAppsForBc -accessToken (az account get-access-token ...) |
Could not identify Aad Tenant |
The Azure CLI service principal does not exist in a fresh tenant | az ad sp create --id 04b07795-8ddb-461a-bbee-02f9e1bf7b46 |
InvalidUniqueTenantIdentifierAsPerAppPolicy |
Modern tenant rejects unqualified App ID URIs; -IncludeApiAccess makes an unverified api. subdomain |
Use a verified domain App ID URI; build S2S by hand |
AADSTS500117 on web sign in |
http reply URL on a non localhost host |
Create the container with -useSSL |
| “Sorry, we couldn’t sign you in” | Authentication Email does not match the Entra UPN | Use a mapped, dedicated member user |
AADSTS500014 (resource disabled) |
No BC subscription, so api.businesscentral.dynamics.com is disabled |
Use the own resource pattern ({appIdUri} scope + ValidAudiences) |
Authentication_InvalidCredentials (401) on a valid token |
The client is not registered in BC’s Microsoft Entra Applications page | Register the client id, Enabled, with a permission set |
cannot use WinRm into the container |
BcContainerHelper cannot open a session into the container | Use docker exec directly |
Invoke-RestMethod fails on the self signed TLS |
Windows PowerShell 5.1 schannel quirk | Use curl.exe -k for the API calls |
Security note
This is a laboratory. The container ships DisableTokenSigningCertificateValidation = True and a 24 hour token lifetime as development conveniences, the certificate is self signed, and ROPC with a no MFA user is fine for a lab but never for production. In production you would harden all of that: a real certificate, a short token lifetime policy, no ROPC.
Conclusion and what’s next
An Azure tenant plus Docker gives you a complete laboratory for OAuth, APIs and development on your own machine, no sandbox required. The OAuth is the same you already know; what you take over is the one job Microsoft does for you in SaaS, telling the service tier which audiences to trust. In SaaS that audience is Microsoft’s resource; in your container it is whatever you put in ValidAudiences, your own App ID URI included.
Next up, the piece that started all this: an Azure Function calling Business Central with these very tokens, and a kill switch from AL. Now that we can mint delegated and service to service tokens against a local container, that webhook lab no longer needs a single paid sandbox.
The full scripts (one per step) and the raw validation log with real output are in the repo: bc-docker-app-registration-lab.