Documentation Index

Fetch the complete documentation index at: https://docs.darwinium.com/llms.txt

Use this file to discover all available pages before exploring further.

Microsoft Entra Native

Prev Next

# 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

  1. What this delivers
  2. Why the architecture looks like this
  3. Before you start
  4. Part A — Azure Front Door
  5. Part B — CloudFront and the Darwinium workers
  6. Part C — Entra configuration for step-up
  7. Part D — the Darwinium journey
  8. The client-side flow
  9. Verification
  10. Operating this in production
  11. Troubleshooting
  12. 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 logs no 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.

Architecture: client to CloudFront to Front Door to Entra, showing the Host header at each hop

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 login prints 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

Front Door profile overview showing SKU Standard, Front Door ID, endpoint hostname, custom domain and origin group

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

Origin group configuration with health probe settings

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

Origin configuration showing Host name and Origin host header both set to the ciamlogin hostname

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

Route configuration showing patterns /*, HTTPS only, origin group, and Enable caching unchecked

### ⚠️ Never enable caching

az afd route show should report cacheConfiguration: null. Caching a
/token response 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 by x-cache: CONFIG_NOCACHE and 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: Succeeded with deploymentStatus: NotStarted is
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

Entra custom domain names blade showing the domain as Verified

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.

CustomUrlDomain is undocumented. The Graph domain resource reference does
not list it among valid supportedServices values and states that only
Email, OfficeCommunicationsOnline and Yammer are 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 return AADSTS399265 for 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.

Front Door Domains blade showing Validation state Approved and certificate Deployed

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 getfunction policy, 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

CloudFront distribution general settings showing the alternate domain name and custom SSL certificate

The origin points at the Front Door endpoint:

CloudFront origin configuration with the origin domain set to the Azure 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

CloudFront behaviours list: nine behaviours, all using Managed-CachingDisabled and Managed-AllViewer

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

Behaviour detail showing CachingDisabled, AllViewer, and the Lambda@Edge origin-request association

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.

ACM certificate in us-east-1, status Issued, associated with the CloudFront distribution

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

Route 53 records showing the CloudFront CNAME, the ACM validation CNAME and the _dnsauth TXT record

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:

  1. Front Door profile, endpoint, origin group, origin with --origin-host-header, route
  2. Domain added and verified on the tenant, and associated as a Custom URL domain
  3. AFD custom domain, validated via _dnsauth TXT, associated with the route
  4. Go/no-go: test the native auth flow against the AFD hostname directly
  5. ACM certificate, CloudFront alias, origin → AFD endpoint, all behaviours → AllViewer
  6. entra.<customer-domain> CNAME → CloudFront
  7. 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.SecurityDefaults is denied even when the
role is present in an app-only token. There is no working API path; use the
portal.

Entra tenant properties showing the Security defaults section

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"] }
}

Conditional Access policies list showing the Darwinium step-up policy with State On

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.ConditionalAccess and
UserAuthenticationMethod.ReadWrite.All are 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.

Authentication method policies blade, with Email OTP enabled for All users as the worked example

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/methods over 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

OverwriteOrInsertContent is the only mode that creates a field that is
absent.
The two Overwrite* modes act only if the field already exists,
and InsertIfNotPresent is not a valid mode. Valid modes are Overwrite,
OverwriteContent, OverwriteOrInsertContent, Before, After,
BeforeContent, AfterContent.

Insertion into application/x-www-form-urlencoded bodies does work.

Do not add a resource scope to the step-up request. Sending
scope=… api://<client-id>/App.Read returns
AADSTS500011: The resource principal … was not found in the tenant unless the
app has an Application ID URI. The claims parameter 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.

Sequence diagram of the six-leg native auth step-up flow through Darwinium

This trace shows an out-of-band (OOB) second factor — email OTP. The
/introspect/challengegrant_type=mfa_oob legs 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, the claims injection, or the
requirement to re-send claims on 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-deploy no steps match entries 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 c3fails 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


Screenshots in this guide are from a reference deployment. Account identifiers,
tenant IDs and subscription IDs have been replaced with placeholders.