# Darwinium risk-based authentication for Microsoft Entra External ID
Deploying Darwinium in front of Entra External ID native authentication, on
Azure + AWS.
This guide shows how to place Darwinium's edge decisioning in the path of
Microsoft Entra External ID's native authentication API, so that every
sign-in is scored in-line and can be allowed, stepped up to MFA, or rejected —
without changing your application's authentication code.
Who this is for. You already have an Entra External ID (CIAM) tenant with
native authentication working, and you have AWS available for CloudFront. You
may or may not already have Azure Front Door — the guide branches at
Part A.
Contents
- What this delivers
- Why the architecture looks like this
- Before you start
- Part A — Azure Front Door
- Part B — CloudFront and the Darwinium workers
- Part C — Entra configuration for step-up
- Part D — the Darwinium journey
- The client-side flow
- Verification
- Operating this in production
- Troubleshooting
- Appendix: parameters
1. What this delivers
Darwinium evaluates a journey on each native-auth request and decides the
outcome of the /token call:
| Outcome | What the user sees | Mechanism |
|---|---|---|
| Allow | Normal sign-in, tokens issued | Request forwarded unchanged |
| Step-up | Challenged for a second factor | Darwinium injects a claims parameter requesting authentication context c3; Conditional Access enforces MFA |
| Reject | "Invalid username or password" | Darwinium terminates the request at the edge and returns a 400 — Entra is never called |
The reject response is deliberately indistinguishable from a wrong password,
so an attacker learns nothing about which signal tripped detection and has
nothing to iterate against. Use a distinctive error only for a demo where the
block needs to be legible; never in production.
2. Why the architecture looks like this
This is the single most important section. Without it, the design looks like
three CDNs stacked for no reason.
Three facts collide:
- Entra only serves the native auth API when the request arrives with
Host: <tenant-name>.ciamlogin.com. Any other value redirects to the
workforce endpoint and surfaces to the caller as a 502. - Darwinium matches journey steps on the incoming Host. If the worker
does not see the hostname the journey is bound to, it logsno steps match
and passes the traffic through untouched. - CloudFront's origin request policy is binary. You either forward the
viewer's Host, or you let the origin's Host be used. There is no way to set an
arbitrary Host, and custom origin headers cannot override it.
So with CloudFront alone you can satisfy one party or the other, never both:
| Origin request policy | Darwinium sees its host? | Entra serves the API? |
|---|---|---|
AllViewer |
✅ steps match | ❌ 502 |
AllViewerExceptHostHeader |
❌ no steps match |
✅ 200 |
Azure Front Door exists in this design solely because its origin
configuration sets an explicit origin host header — the rewrite CloudFront
cannot express. Darwinium sees the public hostname; Entra sees its own.

If you already run Azure Front Door, you do not need a second profile —
you need an origin, origin group and route configured as in
Part A, and the tenant-side custom URL domain
registration. Skip the profile creation.
Front Door is not optional, and here is why
It is tempting to assume that because Front Door rewrites the Host, Entra never
learns a proxy is involved — and therefore that registering a custom URL domain
is unnecessary. This is wrong, and it was tested.
Front Door injects an X-Azure-FDID header identifying the profile. Entra
validates it against the Front Door IDs registered on the tenant, and rejects
anything unregistered:
HTTP/2 400
{"error":"server_error",
"error_description":"AADSTS399265: Request was routed from an invalid domain which is not verified.",
"error_codes":[399265]}
This is deliberate anti-fronting: Microsoft's own custom-URL-domain feature for
External ID is implemented on Azure Front Door, so Entra refuses AFD traffic
from profiles it does not know. Host rewriting is irrelevant — the proxy is
detected by header, not by Host.
Consequence: the custom URL domain registration in
Part A.2 is mandatory.
3. Before you start
You need
| Entra External ID tenant | Native authentication already working, and a linked Azure subscription |
| Azure | Rights to create a Front Door profile; Global Administrator on the External ID tenant |
| AWS | Ability to create a CloudFront distribution, ACM certificates in us-east-1, and cross-account IAM roles |
| DNS | Control of the public hostname you will expose, e.g. entra.<customer-domain> |
| Darwinium | Portal access to create an edge target and deploy a journey |
Check the tenant's linked subscription first
Registering a custom URL domain requires the External ID tenant to be linked to
an Azure subscription. Check this in the Entra admin center overview under
Linked Subscription before concluding the tenant has none.
Trap.
az loginprints a warning like:WARNING: The following tenants don't contain accessible subscriptions. WARNING: <tenant-id> 'Your Tenant Name'That warning is about ARM/RBAC scope — whether a subscription sits inside
that directory. It is a different thing from the tenant's Linked
Subscription, the External ID billing link. Acting on this warning alone has
led to an entire unnecessary subscription being created and transferred
cross-tenant. Check the admin center before believing it.
Also note the portal's My role column lies for guest accounts — the
Subscriptions blade can show My role: - and a disabled Add button while the
CLI shows a genuine Owner assignment. Do not chase this as a permissions problem.
A note on tooling
The Azure portal renders most resource blades inside cross-origin iframes, and
the Entra Conditional Access blades are the same. Everything in this guide is
given as CLI or Microsoft Graph calls, which are reproducible and
scriptable. Screenshots show the resulting state so you can confirm your own.
Part A — Azure Front Door
Already have Front Door? Skip to A.1
and add an origin group, origin and route to your existing profile.
Register the resource provider if it has never been used on the subscription:
az provider register --namespace Microsoft.Cdn
az provider show -n Microsoft.Cdn --query registrationState -o tsv # -> Registered
Create the profile and endpoint. The resource group location is metadata only —
Front Door itself is Global.
RG=rg-dwn-entra-afd
P=afd-dwn-entra
az group create -n $RG -l <region>
az afd profile create -g $RG --profile-name $P --sku Standard_AzureFrontDoor
az afd endpoint create -g $RG --profile-name $P \
--endpoint-name dwn-entra --enabled-state Enabled

A.1 Create the origin with the Host rewrite
az afd origin-group create -g $RG --profile-name $P \
--origin-group-name og-ciamlogin \
--probe-request-type GET --probe-protocol Https \
--probe-interval-in-seconds 120 --probe-path / \
--sample-size 4 --successful-samples-required 3 \
--additional-latency-in-milliseconds 50

This is the line the whole design rests on. --origin-host-header is the
Host rewrite CloudFront's binary origin request policy cannot express:
az afd origin create -g $RG --profile-name $P \
--origin-group-name og-ciamlogin --origin-name ciamlogin \
--host-name <tenant-name>.ciamlogin.com \
--origin-host-header <tenant-name>.ciamlogin.com \
--http-port 80 --https-port 443 --priority 1 --weight 1000 \
--enabled-state Enabled --enforce-certificate-name-check true

Then the route. Note there is no --enable-caching flag — see the warning
below.
az afd route create -g $RG --profile-name $P \
--endpoint-name dwn-entra --route-name default-route \
--origin-group og-ciamlogin \
--supported-protocols Https --patterns-to-match "/*" \
--forwarding-protocol MatchRequest \
--link-to-default-domain Enabled --https-redirect Disabled \
--enabled-state Enabled

### ⚠️ Never enable caching
az afd route showshould reportcacheConfiguration: null. Caching a
/tokenresponse would serve one user's continuation or access token to
another. This applies at both CDN layers.
Test on the azurefd.net hostname before touching DNS
Because the origin host header rewrite makes Entra see its own hostname
regardless of what the client asked for, you can prove the whole path on the
AFD-generated hostname alone — no DNS, no TXT validation, no certificate.
Only if that works is there any point requesting DNS changes.
### ⚠️ Propagation is ~15 minutes, and you must not poke it
Immediately after creation the endpoint 404s on every path. That is Front
Door's own 404, distinguishable byx-cache: CONFIG_NOCACHEand the absence
of Entra headers (x-ms-request-id,x-ms-ests-server).az afd route show -g $RG --profile-name $P --endpoint-name dwn-entra \ --route-name default-route \ --query "{prov:provisioningState,deploy:deploymentStatus}"
provisioningState: SucceededwithdeploymentStatus: NotStartedis
normal right after creation — the control plane accepted the config but the
edge has not taken it yet.Do not "nudge" it with
az afd route update. Every route write restarts
the propagation clock. Two nudges here turned a 15-minute wait into a 30-minute
one and manufactured evidence of a fault that never existed. Poll, don't touch.
The transition to look for is 404 → anything else; a 400 is progress, because
it means the request is reaching Entra.
A.2 Register the domain on the Entra tenant
This is what tells Entra to trust your specific Front Door. Microsoft documents
it as a portal walkthrough; it is fully scriptable over Graph.
TID=<tenant-id>
G=$(az account get-access-token --tenant $TID \
--resource https://graph.microsoft.com --query accessToken -o tsv)
# 1. add the domain
curl -X POST -H "Authorization: Bearer $G" -H "Content-Type: application/json" \
-d '{"id":"entra.<customer-domain>"}' https://graph.microsoft.com/v1.0/domains
# 2. read the TXT value Entra wants
curl -H "Authorization: Bearer $G" \
https://graph.microsoft.com/v1.0/domains/entra.<customer-domain>/verificationDnsRecords
# 3. once the DNS record exists, verify
curl -X POST -H "Authorization: Bearer $G" -H "Content-Length: 0" \
https://graph.microsoft.com/v1.0/domains/entra.<customer-domain>/verify

Then associate it as a Custom URL domain:
curl -X PATCH -H "Authorization: Bearer $G" -H "Content-Type: application/json" \
-d '{"supportedServices":["CustomUrlDomain"]}' \
https://graph.microsoft.com/v1.0/domains/entra.<customer-domain>
# -> 204, then supportedServices reads ['CustomUrlDomain']
This is the step whose absence produces AADSTS399280 InvalidCustomUrlDomain.
CustomUrlDomainis undocumented. The Graph domain resource reference does
not list it among validsupportedServicesvalues and states that only
OfficeCommunicationsOnlineandYammerare settable by API. It
works anyway — verified end to end. Because it is undocumented it could change
without notice; the portal path (Entra ID → Domain names → Custom URL
domains) is the supported fallback.
Allow ~5 minutes for propagation, and do not judge it early. After the
PATCH, requests continue to returnAADSTS399265for several minutes. Two
failing samples is not enough to conclude the call was a no-op.
A.3 Add the AFD custom domain
az afd custom-domain create -g $RG --profile-name $P \
--custom-domain-name entra-<customer-domain> \
--host-name entra.<customer-domain> \
--minimum-tls-version TLS12 --certificate-type ManagedCertificate
# returns validationProperties.validationToken -> the _dnsauth TXT value
az afd route update -g $RG --profile-name $P --endpoint-name dwn-entra \
--route-name default-route --custom-domains entra-<customer-domain>
Publish the _dnsauth.entra.<customer-domain> TXT record with the returned
token. Validation completes within a minute or two of the record going live.

Note the DNS state column showing a warning that the CNAME/alias record does
not point at Front Door. That is expected and correct in this architecture —
the hostname must CNAME to CloudFront, not to Front Door. See
§10 Certificate rotation for the
operational consequence.
The managed certificate takes roughly 9 minutes to deploy. Until it does,
Front Door answers that SNI with a *.azureedge.net certificate and TLS fails.
Part B — CloudFront and the Darwinium workers
Follow the Darwinium CloudFront Deployment
guide for the parts that are not specific to this integration:
- Step 1 — note your External ID from the Darwinium portal
- Step 2 — note your AWS Account ID
- Steps 4–7 — the
getfunctionpolicy, the Lambda@Edge execution role, the
Darwinium deployment policy and the deployment role - Step 8 — enter the distribution ID and role ARNs back into the portal
Everything below is where this integration deliberately departs from that
guide. The differences are not optional.
B.1 Distribution settings that differ
| Setting | Darwinium's general guidance | Use here | Why |
|---|---|---|---|
| Cache policy | CachingOptimized |
CachingDisabled |
Caching a /token response would serve one user's tokens to another |
| Origin request policy | AllViewer or AllViewerExceptHostHeader |
AllViewer (required) |
Darwinium must see the viewer's Host to match journey steps |
| Origin domain | your application origin | the AFD endpoint <afd-endpoint>.azurefd.net |
Front Door performs the Host rewrite |
| Allowed methods | — | GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE |
The native auth API is POST-based |
| Viewer protocol policy | — | HTTPS only |

The origin points at the Front Door endpoint:

B.2 Behaviours
Create a behaviour per native-auth path, plus the matching
/dwn_redirect/... paths that the Darwinium workers use for their internal
loopback. All of them use CachingDisabled + AllViewer.
/<tenant-id>/oauth2/v2.0/initiate
/<tenant-id>/oauth2/v2.0/challenge
/<tenant-id>/oauth2/v2.0/introspect
/<tenant-id>/oauth2/v2.0/token
/dwn_redirect/<tenant-id>/oauth2/v2.0/initiate
/dwn_redirect/<tenant-id>/oauth2/v2.0/challenge
/dwn_redirect/<tenant-id>/oauth2/v2.0/introspect
/dwn_redirect/<tenant-id>/oauth2/v2.0/token

A single behaviour, showing the policy pair and the Darwinium Lambda@Edge
association on origin request with Include body enabled:

Both tenant path forms work at Entra —
/<tenant-id>/…and
/<tenant-name>.onmicrosoft.com/…. A rule bound to only one form is
bypassable via the other. Microsoft's own WAF route example uses only the
GUID form. If you are relying on path-scoped behaviours or WAF rules as a
control, cover both forms.
B.3 Certificate and DNS
Request the certificate in us-east-1 — CloudFront will not accept one from
any other region — and attach it as the distribution's custom SSL certificate
with entra.<customer-domain> as an alternate domain name.

Three DNS records are involved:
| Name | Type | Value | Purpose |
|---|---|---|---|
entra.<customer-domain> |
CNAME | <distribution-domain>.cloudfront.net |
Public entry point |
_dnsauth.entra.<customer-domain> |
TXT | AFD validation token | Front Door custom domain |
_<hash>.entra.<customer-domain> |
CNAME | ACM validation target | ACM certificate |

The Entra tenant-verification TXT (
MS=ms…) must be removed once the
domain is verified — a CNAME cannot coexist with a TXT record at the same name,
and this hostname needs the CNAME.
B.4 Build order
Order matters. This sequence works:
- Front Door profile, endpoint, origin group, origin with
--origin-host-header, route - Domain added and verified on the tenant, and associated as a Custom URL domain
- AFD custom domain, validated via
_dnsauthTXT, associated with the route - Go/no-go: test the native auth flow against the AFD hostname directly
- ACM certificate, CloudFront alias, origin → AFD endpoint, all behaviours →
AllViewer entra.<customer-domain>CNAME → CloudFront- Deploy the Darwinium journey last, so its validator sees the final configuration
Between step 6 and step 7 you will see intermittent
AADSTS399265, failing
on a different leg each run. That is stale workers, not broken routing — they
were deployed against the previous origin and re-issue their/dwn_redirect/
loopback with a Host Entra no longer accepts. A genuine routing fault fails
every leg deterministically. Redeploying the journey clears it.
Part C — Entra configuration for step-up
Everything in this part is required for the step-up outcome. Skip any of it
and the step-up silently fails open — Entra stamps c3 into acrs and issues
tokens anyway, which is indistinguishable from success.
C.1 Disable Security Defaults — do this first
This is the single most likely thing to stop you. Security Defaults and
Conditional Access are mutually exclusive. While Security Defaults is enabled,
creating any CA policy fails:
BadRequest: Security Defaults is enabled in the tenant.
You must disable Security defaults before enabling a Conditional Access policy.
This is not mentioned in Microsoft's third-party ATO protection tutorial.
Toggle it at Entra ID → Overview → Properties → Manage security defaults.
Portal only.
Policy.ReadWrite.SecurityDefaultsis denied even when the
role is present in an app-only token. There is no working API path; use the
portal.

Once a Conditional Access policy exists, the blade confirms the exclusion from
the other direction: "Your organization is currently using Conditional Access
policies which prevents you from enabling security defaults."
⚠️ Disabling Security Defaults is a real reduction in baseline protection.
The Conditional Access policy you create below must genuinely replace it.
Review your tenant's baseline before proceeding.
C.2 Create the authentication context
The ID is chosen explicitly; there is no need to create c1/c2 first.
PATCH https://graph.microsoft.com/v1.0/identity/conditionalAccess/authenticationContextClassReferences/c3
Content-Type: application/json
{"id":"c3","displayName":"Darwinium step-up","isAvailable":true}
Visible in the portal at Conditional Access → Authentication contexts.
C.3 Create the Conditional Access policy
POST https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies
Content-Type: application/json
{
"displayName": "Darwinium step-up - require MFA for auth context c3",
"state": "enabled",
"conditions": {
"applications": { "includeAuthenticationContextClassReferences": ["c3"] },
"users": { "includeUsers": ["All"] }
},
"grantControls": { "operator": "OR", "builtInControls": ["mfa"] }
}

Targeting All users is safe here. The authentication context only applies
when Darwinium injects it, so ordinary sign-ins are unaffected.
Permissions — what works and what does not
The Azure CLI's first-party application cannot do any of this. Its token
never carries Policy.ReadWrite.ConditionalAccess,
Policy.ReadWrite.SecurityDefaults or UserAuthenticationMethod.ReadWrite.All,
and you cannot add scopes to it. Being Global Administrator is irrelevant — this
is a scope limitation, not a role one.
| Permission | App-only (client credentials) |
|---|---|
Policy.ReadWrite.ConditionalAccess — authentication contexts |
✅ works |
UserAuthenticationMethod.ReadWrite.All — register methods |
✅ works |
| Conditional Access policies endpoint | ❌ denied — use a delegated user token |
Policy.ReadWrite.SecurityDefaults |
❌ denied — portal toggle only |
External ID tenants appear to require delegated context for some policy
endpoints regardless of app roles.
Clean up afterwards. If you grant app roles to a confidential application
to perform this setup, revoke them when you are done.
Policy.ReadWrite.ConditionalAccessand
UserAuthenticationMethod.ReadWrite.Allare powerful and tenant-wide.
C.4 Enable the MFA methods you intend to use
Which second factor you offer is your choice — passkeys, Microsoft
Authenticator, SMS, software OATH tokens and email OTP are all valid ways to
satisfy the Conditional Access grant. The step-up mechanism in this guide is
method-agnostic: Darwinium requests authentication context c3, Conditional
Access requires MFA, and Entra challenges with whatever the user has registered.
Enable your chosen method(s) at Authentication methods → Policies and set an
appropriate target group.

The screenshot and the walkthrough below use email OTP because it is the
lowest-friction method to prove the flow end to end on a test tenant. Swap in
whatever your production posture calls for — the rest of the integration does
not change.
C.5 Register a method on the user
Users must have a registered, MFA-capable method or the step-up cannot
complete, whichever method you choose.
"No usable methods" in the portal ≠ no methods. It means no MFA-capable
ones; a password method is still present. Read
GET /users/{id}/authentication/methodsover Graph for the truth.
### ⚠️ If you use email OTP: the portal's per-user Add authentication method → Email is a dead end
It states plainly that email "cannot be used for authentication" — it
registers an SSPR-only method, which will not satisfy MFA. Only the Graph
endpoint produces a usable one:POST https://graph.microsoft.com/v1.0/users/{id}/authentication/emailMethods Content-Type: application/json {"emailAddress":"<user-email>"}Methods that users enrol themselves (passkeys, Authenticator) do not have this
problem — they are registered through the normal registration flow.
Part D — the Darwinium journey
The journey defines four steps matching the native-auth endpoints. All four bind
to the host alias representing your public hostname, and the token step
carries the decisioning.
api_version: darwinium.com/api/journey/v1
journey_name: entra-native-auth
steps:
- step_name: initiate
proxy_event:
url: /<tenant-id>/oauth2/v2.0/initiate
host: entrahost
method: POST
url_match_type: Exact
request:
multipart_body_rules:
- name: username
extract_to_attribute: custom.general_purpose['username']
- name: capabilities
extract_to_attribute: custom.general_purpose['capabilities']
response:
body_rule:
jsonpath:
- name: $.continuation_token
extract_to_attribute: primary_session_tie
event_type: misc_other
Declare the alias in journeys.yaml:
journeys:
- entra-native-auth.journey.yaml
targets:
- name: cloudfront
type: cloudfront
enabled: true
valid_host_list:
- entrahost
D.1 The step-up injection
On the token step, the claims field is inserted into the form-encoded body
when the score crosses your step-up threshold:
- name: claims
inject:
content: '{"access_token":{"acrs":{"essential":true,"value":"c3"}}}'
mode: OverwriteOrInsertContent
condition: outcome[CHAMPION].models.score["token.rba"] < -300
OverwriteOrInsertContentis the only mode that creates a field that is
absent. The twoOverwrite*modes act only if the field already exists,
andInsertIfNotPresentis not a valid mode. Valid modes areOverwrite,
OverwriteContent,OverwriteOrInsertContent,Before,After,
BeforeContent,AfterContent.Insertion into
application/x-www-form-urlencodedbodies does work.
Do not add a resource scope to the step-up request. Sending
scope=… api://<client-id>/App.Readreturns
AADSTS500011: The resource principal … was not found in the tenantunless the
app has an Application ID URI. Theclaimsparameter alone is correct.
D.2 The reject outcome
Termination is achieved by omitting continuation_token — the client then
has nothing to carry into /introspect or a further /token. The status code
and error text only control how it looks.
proxy_action:
name: proxy
dependencies: [token.rba]
execute_code: proxy.rules
response_body:
template: >-
{
"error": "invalid_grant",
"error_description": "AADSTS50126: Error validating credentials due to invalid username or password.",
"error_codes": [50126],
"timestamp": "{{ timestamp }}",
"suberror": "invalid_password"
}
D.3 A trap that costs hours: + decodes to a space
Form-encoding decodes a literal + as a space. A condition written against
user+tag@example.com will never match, because the extracted attribute
holds user tag@example.com.
There is no error and no log line — the symptom is indistinguishable from a
broken injection mode, an unsatisfied dependency, or an unsupported body type.
Write conditions against the decoded form, or match both:
identity[ACCOUNT].username.username = "user+tag@example.com"
OR identity[ACCOUNT].username.username = "user tag@example.com"
The same applies to any client sending a plus-addressed username: use
--data-urlencode (or the equivalent) rather than interpolating into a -d
string.
8. The client-side flow
Your application drives six legs. Legs 4–6 only occur when Darwinium triggers a
step-up.

This trace shows an out-of-band (OOB) second factor — email OTP. The
/introspect→/challenge→grant_type=mfa_ooblegs are the OOB pattern,
shared by email and SMS. If you use a different method the challenge legs
differ; what does not change is legs 1–3, theclaimsinjection, or the
requirement to re-sendclaimson the final/token.
Wire-format traps
Every one of these produces a misleading error or a silent success.
| Trap | Symptom | Fix |
|---|---|---|
capabilities is space-separated |
AADSTS901020: unsupported capability |
capabilities=mfa_required registration_required — not comma-separated |
grant_type must be mfa_oob, not oob |
AADSTS55200: The continuation_token is invalid — the token is fine |
Use mfa_oob on the final leg. Do not chase token expiry when you see 55200 |
registration_required capability omitted |
HTTP 200 with {"challenge_type":"redirect", …} — no token, no error |
Always send it when the user may have no method registered |
claims not re-sent on the final /token |
Step-up completes but acrs lacks c3 |
The auth context must be requested on the call that completes the flow, not only the one that triggers it |
| OOB continuation tokens expire in well under two minutes | AADSTS55200 on a slow round trip |
Submit promptly; treat 55200 as "restart the flow", not "retry" |
+ in a form body decodes to a space |
Plus-addressed logins fail with user_not_found |
Use --data-urlencode |
If you build against a mock first
A mock will pass while live Entra fails, unless you handle these differences:
| Mock | Live Entra | |
|---|---|---|
| Step-up suberror | credential_required / 50076 |
mfa_required / 50074 |
| Email OTP length | 6 | 8 |
Final acrs |
['c3'] |
['c3','p1','urn:user:registersecurityinfo','pfdr'] |
Anything hard-coding an OTP length, matching on 50076, or testing
acrs == ['c3'] rather than 'c3' in acrs will pass on a mock and fail live.
Read code_length from the /challenge response rather than assuming it.
9. Verification
A green flow proves nothing on its own. Under
AllViewerExceptHostHeader the API returns 200 precisely because Darwinium is
doing nothing. You must check two independent conditions.
The oracle
Decode the returned access token's payload:
| Check | Meaning |
|---|---|
acrs contains c3 |
Darwinium's injection reached Entra |
acrs contains p1 |
MFA was actually performed |
acrs = ['urn:user:registersecurityinfo','pfdr'] only |
The injection did not land |
Confirm Darwinium saw the request
In the Lambda@Edge logs, look for matched steps on initiate, challenge and
token, with zero no steps match.
# The PoP moves, so scan regions and match the [N] in the log stream
# name to the deployed worker version.
for R in us-east-1 us-east-2 us-west-2 ap-southeast-2 eu-west-1; do
aws logs describe-log-streams --region $R \
--log-group-name /aws/lambda/us-east-1.dwn_e10x_cloudfront_orequest_<target>_token \
--order-by LastEventTime --descending --max-items 1 \
--query 'logStreams[0].logStreamName' --output text 2>/dev/null
done
Scope the log window to after the journey redeploy. A wider window mixes
in pre-deployno steps matchentries and makes a clean result look broken.
Lambda@Edge logs go to the PoP's region, and the PoP moves. Looking in the
wrong region produces a confident, wrong "the workers aren't running"
conclusion. Always scan, and always check the[N]version in the stream name
matches what is deployed.
Sanity-check the oracle itself
Send claims by hand. This works both direct to Entra and through the full
proxy chain:
curl -X POST "https://entra.<customer-domain>/<tenant-id>/oauth2/v2.0/token" \
--data-urlencode "username=<test-user>" \
--data-urlencode "password=<password>" \
-d "client_id=<client-id>&continuation_token=<ct>&grant_type=password&scope=openid offline_access" \
--data-urlencode 'claims={"access_token":{"acrs":{"essential":true,"value":"c3"}}}'
A successful response carries acrs containing c3.
10. Operating this in production
Certificate rotation needs a diary entry
Because entra.<customer-domain> must CNAME to CloudFront so the Darwinium
workers see the traffic, it does not CNAME to Front Door. Per the Azure Front
Door documentation, AFD does not auto-rotate a managed certificate when the
custom domain's CNAME points anywhere other than the AFD endpoint.
Roughly 45 days before expiry the domain enters Pending Revalidation and
someone must publish a fresh _dnsauth TXT record by hand. If nobody does, the
certificate lapses and TLS breaks silently.
Two options:
- Accept and diarise — fine for a demo or pilot, provided it is written down.
- Bring your own certificate via Key Vault — no revalidation cycle. The key
vault must be in the same subscription as the AFD profile. Switching from
managed to BYOC does not require revalidation.
Caching stays off at both layers
Worth repeating because it is the highest-severity misconfiguration available
here: caching a /token or /challenge response can serve one user's
credentials to another.
Path-form ambiguity is a real control gap
Both /<tenant-id>/… and /<tenant-name>.onmicrosoft.com/… are served by
Entra. Any control — WAF rule, CloudFront behaviour, journey step — bound to one
form is bypassable via the other. Cover both.
Cost
Front Door Standard carries a monthly base charge plus usage. If you abandon the
approach, delete the whole resource group to stop billing:
az group delete -n rg-dwn-entra-afd --yes
11. Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
AADSTS399265 on every leg |
Custom URL domain not registered, or Front Door ID not trusted by the tenant | Complete A.2; allow ~5 minutes |
AADSTS399265 on a different leg each run |
Stale Darwinium workers from a previous origin | Redeploy the journey |
AADSTS399280 InvalidCustomUrlDomain |
Domain verified but not associated as a Custom URL domain | PATCH supportedServices: ["CustomUrlDomain"] |
404 on every path, x-cache: CONFIG_NOCACHE |
Front Door route has not propagated | Wait ~15 min from the last write; do not re-run az afd route update |
| 502 from CloudFront | Origin request policy is AllViewer but the origin is Entra directly, not Front Door |
Point the origin at the AFD endpoint |
200 responses but no steps match in logs |
Origin request policy is AllViewerExceptHostHeader |
Switch to AllViewer |
BadRequest: Security Defaults is enabled |
Security Defaults still on | C.1 |
| Step-up "works" but no MFA prompt | No CA policy for context c3 — fails open |
C.3; verify p1 in acrs |
HTTP 200 with {"challenge_type":"redirect"} |
registration_required capability missing and user has no method |
Add the capability; register a method via Graph |
AADSTS901020: unsupported capability |
capabilities sent comma-separated |
Use spaces |
AADSTS55200: continuation_token is invalid on the final leg |
grant_type=oob instead of mfa_oob, or token expired |
Check the grant type first |
AADSTS500011: resource principal not found |
A resource scope was added to the step-up request | Send claims alone |
| Injection never fires for a plus-addressed user | + decoded to a space |
Match the decoded form |
| TLS failure shortly after adding the custom domain | Managed certificate still deploying (~9 min) | Wait |
| TLS failure months later | Managed certificate lapsed — no auto-rotation | §10 |
12. Appendix: parameters
Replace throughout:
| Placeholder | Meaning | Example shape |
|---|---|---|
entra.<customer-domain> |
Public hostname you expose | entra.example.com |
<tenant-name> |
External ID tenant name | contosociam |
<tenant-id> |
External ID tenant GUID | 00000000-0000-0000-0000-000000000000 |
<client-id> |
Native auth public client application ID | GUID |
<afd-endpoint> |
Front Door endpoint hostname | dwn-entra-xxxx.z01.azurefd.net |
<front-door-id> |
Front Door profile ID, registered on the tenant | GUID |
<distribution-id> / <distribution-domain> |
CloudFront distribution | E… / d….cloudfront.net |
<aws-account-id> |
AWS account hosting the distribution | 12 digits |
<hosted-zone-id> |
Route 53 zone for the domain | Z… |
<target> |
Darwinium edge target name | entraweb |
Reference
- Darwinium — CloudFront Deployment
- Darwinium — Journey lifecycle management
- Microsoft — Third-party account takeover protection with native authentication
- Microsoft — Azure Front Door certificate rotation
Screenshots in this guide are from a reference deployment. Account identifiers,
tenant IDs and subscription IDs have been replaced with placeholders.