# Darwinium risk-based authentication for Okta
Scoring every Okta-hosted sign-in inline with Darwinium, enforced by an Okta
Token Inline Hook — with no change to your application's authentication code.
This guide shows how to put Darwinium's decisioning in the path of an
Okta-hosted sign-in, so that each login is profiled in the browser, scored by
a Darwinium journey, and — at the moment Okta mints the token — allowed, stepped
up, or blocked by a Token Inline Hook.
Who this is for. You run authentication on Okta with the Okta-hosted
sign-in page (a custom domain such aslogin.<your-domain>), and you can
stand up one internet-reachable HTTPS endpoint for the hook and the profiling
collector. You have Darwinium portal access to deploy a journey. No changes to
your relying-party application are required.
Contents
- What this delivers
- How it works, and why it looks like this
- Before you start
- Part A — Okta configuration
- Part B — the Darwinium journey
- Part C — the edge middleware
- The sign-in flow
- Verification
- Operating this in production
- Troubleshooting
- Appendix: parameters
1. What this delivers
Darwinium evaluates a journey for each sign-in and decides the outcome of Okta's
token mint:
| Outcome | What the user sees | Mechanism |
|---|---|---|
| Allow | Normal sign-in, tokens issued | Hook returns 200 { "commands": [] } |
| Step-up | Challenged for a second factor | Darwinium decision drives an Okta step-up policy (or is treated as a block for a proof-of-value) |
| Reject | Sign-in fails, no token issued | Hook returns 200 { "error": { … } } — Okta abandons the token mint |
The block is delivered at the token-issuance step, so the user has already
entered valid credentials before Darwinium's device and behavioural signals veto
the login.
### ⚠️ This is a fail-open control by design
A Token Inline Hook fails open. If your endpoint times out, errors, or
returns any non-200, Okta issues the token anyway. Token hooks also do
not retry. To block you must return HTTP 200 with anerrorobject
within Okta's timeout (~3s). If you need fail-closed enforcement, put
Darwinium's edge decisioning directly in the request path instead of relying on
the token hook. Treat the hook as risk-based friction, not a hard gate.
2. How it works, and why it looks like this
Three facts shape the whole design. Understanding them is what stops the setup
from looking like a pile of unrelated parts.
1. Only a custom authorization server fires token inline hooks. Okta's
org authorization server (issuer https://<your-okta-org> with no
/oauth2/{id}) — the one behind the Okta dashboard and plain SSO — never fires a
token hook. You must mint the login's token from a custom authorization
server, and the hook only fires once it is bound to that server's access-policy
rule. Register the hook and nothing happens; it does nothing until step
A.4.
2. The hook and the sign-in page must agree on a per-login correlator. The
profiling data is gathered in the browser on the sign-in page; the decision is
needed later, server-to-server, when Okta calls the hook. Something must tie the
two together. The cleanest tie is the OAuth state parameter: it is a
structured value your app already sets uniquely per login, it is readable on the
sign-in page (OktaUtil.getRequestContext().authentication.request.state), and
it arrives in the hook payload at
data.context.protocol.request.state. Darwinium keys the journey on it as
primary_session_tie.
Custom
/authorizeparameters do not survive cleanly. Only known OAuth
parameters land indata.context.protocol.request. A custom parameter (e.g.
dwn_tie) is dropped fromprotocol.requestand survives only inside the
raw authorize URL atdata.context.request.url.value, which you would have to
parse. Usestateunless you have no usablestate, in which case inject and
parse a dedicated parameter.
3. The browser can't reach the Darwinium mTLS Event API, so the collector and
the hook live on an edge/CDN endpoint. Darwinium's Event API is mutual-TLS on
port 9443 — a browser cannot present a client certificate. So the profiling
blob is posted to a first-party edge endpoint (a CloudFront distribution that
also serves the profiling script), which simplifies CSP and CORS; the same
distribution hosts the token-hook endpoint. That edge function is the only thing
holding the Darwinium client certificate.
The resulting shape:
.png?sv=2026-02-06&spr=https&st=2026-08-21T19%3A21%3A23Z&se=2026-08-21T19%3A53%3A23Z&sr=c&sp=r&sig=U7UVb1BWUqhiwXG4q271HfTwpcfc3l5QNY7XUhqkBEc%3D)
One state value threads the browser-side profiling event to the server-side
decision event.
3. Before you start
You need
| Okta org | With a custom domain (e.g. login.<your-domain>) — this is what unlocks the sign-in-page code editor and CSP customisation |
| Okta admin | Super admin, or admin with app / authorization-server / customization / inline-hook rights |
| An HTTPS endpoint | Internet-reachable by Okta, hosting the token-hook endpoint and receiving the profiling blob. This guide uses a CloudFront distribution with a Lambda@Edge function |
| Darwinium | A node with an Event-API mTLS client certificate, and portal access to deploy a journey |
The moving parts
| Component | Where it lives | Role |
|---|---|---|
| Custom authorization server | Okta | Mints the login token and fires the hook |
| OIDC application | Okta | The relying party that logs the user in |
| Token Inline Hook | Okta → your edge endpoint | Calls Darwinium at token mint; can block |
| Custom sign-in page | Okta (brand) | Loads profiling, posts the blob keyed by state |
dwn_profiling.js + /__collect + /okta/token-hook |
Edge (CloudFront/Lambda@Edge) | Serve profiling, ingest the blob, run the hook |
The journey (web_collect, login, token_process) |
Darwinium | Ingests profiling, scores the login, returns the decision |
Part A — Okta configuration
A.1 Custom Authorization Server
Security → API → Authorization Servers.
Token inline hooks fire only when a custom authorization server mints the
token — never on the org server. Use the built-in default custom server (issuer
https://<your-okta-org>/oauth2/default) or create a dedicated one, and make
sure your app requests its token from that issuer.

Request openid profile email at minimum; profile is what makes
preferred_username / email available in the hook payload.

A.2 OIDC Application
Applications → Create App Integration → OIDC - OpenID Connect.
- Application type: this reference uses a Single-Page App (SPA) with
Authorization Code + PKCE. Web and Native app types work too. - Grant type: Authorization Code (PKCE for SPA/native).
- Sign-in redirect URI(s): your application's callback.
- The app must obtain its token from the custom authorization server, not the
org server — otherwise the hook never fires.

A.3 Register the Token Inline Hook
Workflow → Inline Hooks → Add Inline Hook → Token.

- Name: e.g.
darwinium-risk-hook. - URL: your edge endpoint, e.g.
https://<your-cloudfront-domain>/okta/token-hook. - Authentication (recommended): add an HTTP header the middleware verifies, so
only Okta can invoke the hook. The deliveredeventTypeis
com.okta.oauth2.tokens.transform. - Set status Active.

Registering the hook only defines the endpoint. It does nothing until it is
bound in the next step.
A.4 Bind the hook to the access-policy rule — the step everyone misses
Security → API → Authorization Servers → {your custom server} → Access
Policies.
- Add Policy and assign it to your app (or All Clients).
- Add Rule, include the Authorization Code grant, and set your user /
scope conditions. - In the rule, set "Use this inline hook" → select your token hook → save.

Without a policy and rule on the custom server, the server cannot mint tokens for
any client and there is nowhere to attach the hook — so it can never fire.
The binding is inside the rule, under THEN Use this inline hook:

A.5 Custom domain & brand
Customizations → Brands.
The custom domain (login.<your-domain>) is what unlocks the sign-in-page code
editor and CSP customisation. Confirm the brand carrying your custom domain.

Under that brand's Domains tab, the custom domain should read Active with
a valid certificate:

A.6 Sign-in page code — inject Darwinium profiling
Customizations → Brands → {brand} → Pages → Sign-in page → Page Design (code
editor).
Inside the page's nonce'd script block: load dwn_profiling.js, read the login's
state, and — once, only on the password submit — post the profiling blob
(in the dwn-profiling header) to /__collect, keyed by state.

<script src="https://<your-cloudfront-domain>/dwn_profiling.js" nonce="{{nonceValue}}"></script>
<script type="text/javascript" nonce="{{nonceValue}}">
var config = OktaUtil.getSignInWidgetConfig();
var oktaSignIn = new OktaSignIn(config);
// The per-login correlator, straight off the authorize request.
var dwnTie = (OktaUtil.getRequestContext().authentication.request || {}).state;
var dwnP;
try { dwnP = (typeof dwn !== 'undefined' && dwn.start) ? dwn.start({ mouse: true }) : null; } catch (e) {}
var dwnSent = false;
document.addEventListener('submit', function (e) {
if (dwnSent) return;
var f = e.target;
var pwd = f && f.querySelector &&
(f.querySelector('input[type="password"]') || f.querySelector('input[name="credentials.passcode"]'));
if (!pwd) return; // skip identify + MFA submits; only the password form
dwnSent = true;
Promise.resolve(dwnP && (dwnP.collect ? dwnP.collect() : (dwnP.tryCollect ? dwnP.tryCollect() : null)))
.then(function (profileBlob) {
fetch('https://<your-cloudfront-domain>/__collect?primary_session_tie=' + encodeURIComponent(dwnTie), {
method: 'GET', keepalive: true,
headers: { 'dwn-profiling': profileBlob }
}).catch(function (e) { console.log('[DWN] collect post failed', e); });
});
}, true);
oktaSignIn.renderEl({ el: '#okta-login-container' }, OktaUtil.completeLogin,
function (err) { console.log(err.message, err); });
</script>
Then Save to draft → Publish.
### ⚠️ Every
<script>you add must carrynonce="{{nonceValue}}"The sign-in page enforces a strict Content-Security-Policy. A script tag
without the nonce is silently blocked — your HTML renders, but no
JavaScript runs and there are no console logs. This is the single most common
reason "nothing happens".
The capture hooks the native
submitevent. The third-generation Okta
Sign-In Widget (^7) does fire a realsubmitevent, which a capture-phase
listener catches before the widget submits. The password-form check ensures a
single collect per login (it skips the identifier and MFA submits).
A.7 Content Security Policy — trust the external hosts
Sign-in page → Settings → Content Security Policy → Edit → Trusted external
resources. Add each host (one per entry) and keep enforcement Enforced:
- the profiling script host (the CDN serving
dwn_profiling.js) - the Darwinium profiling collection hosts (e.g.
aps.<node>,ats.wowscale.com,
*.dxf.wowscale.com) - the collect endpoint host that receives the
dwn-profilingPOST

If a request is CSP-blocked you will see
Refused to connect/Failed to fetchin the browser console — that host needs to be added here. Publish after
editing.
Part B — the Darwinium journey
The Okta side delivers two things to Darwinium, both keyed by the OAuth state
(passed as primary_session_tie): the profiling blob (from the sign-in page
at password submit) and the token-hook call (from Okta at token mint). The
journey defines the steps that receive them.

| Step | Type | Trigger | Role |
|---|---|---|---|
Tag Profiling Step |
snippet (dwn_profiling_step) |
serves /dwn_profiling.js |
The profiling tag the sign-in page loads |
web_collect |
proxy | GET /__collect |
Ingests the profiling blob; produces the PROFILING group keyed by primary_session_tie |
web_collect_options |
proxy | OPTIONS /__collect |
CORS preflight for the collect request |
login |
API event (account_login) |
POST /api/event/{journey}/login |
The scored login event; imports web_collect.PROFILING and runs the Decision Strategy |
token_process |
proxy | POST /__token |
Alternative entry point when the raw Okta hook payload is proxied through Darwinium |
B.1 web_collect — ingest the profiling blob
The browser sends the blob (in the dwn-profiling header) to /__collect with
?primary_session_tie=<state>. This proxy step extracts the tie and produces the
PROFILING group under it.

url_match_type: ExactWithQuery— the query string is part of the match.- The CORS
access-control-allow-originmust equal your sign-in page origin
(e.g.https://login.<your-domain>) or the browser rejects the response. - The blob rides in the
dwn-profilingrequest header, which must be listed in
access-control-allow-headers.
B.2 login — the scored event that imports profiling
This is the decision step: an account_login API event that imports the
PROFILING group from web_collect via the shared primary_session_tie. That
import is what surfaces the device and behavioural signals on the login event so
the Decision Strategy can score them.

The hook calls this step with the same primary_session_tie and the
authenticated username; Darwinium resolves them into one journey, surfaces the
profiling, and the Decision Strategy emits pass | challenge | reject. That
outcome.CHAMPION.decision_strategy.result is the value the hook reads.
B.3 token_process — reading the correlator out of the hook payload
The token_process proxy step shows exactly how the correlator is extracted from
the Okta hook payload. It maps the hook's JSON body:

The body mappings are the heart of the correlation — the same paths the
middleware reads:
| JSONPath in the hook payload | Extracted to |
|---|---|
$.data.context.protocol.request.state |
primary_session_tie |
$.data.context.session.login |
identity['ACCOUNT'].username.username |

Part C — the edge middleware
The Okta-facing token-hook endpoint (and the collect endpoint) run as a
Lambda@Edge function on the CloudFront distribution that also serves
dwn_profiling.js. Edge is used so the endpoints sit on the first-party domain —
simplifying CSP and CORS — and can generate the response without an origin.
C.1 What the function does
OPTIONS -> 204 + CORS (preflight)
GET x-okta-verification-... -> 200 { verification: <challenge> } (Okta hook one-time verify)
POST /okta/token-hook -> read state + login from the payload;
call Darwinium `login` (imports web_collect.PROFILING);
decision 'reject' -> 200 { error: {…} } (blocks the login)
otherwise -> 200 { commands: [] } (issues the token)
timeout / any error -> 200 { commands: [] } (fail-open)
The reject response uses Okta's error-object shape so the token mint is
abandoned:
{
"error": {
"errorSummary": "Access denied — elevated risk (Darwinium)",
"errorCauses": [
{ "errorSummary": "Darwinium decision: reject", "reason": "high_risk" }
]
}
}
C.2 The function source
Save this as index.js. The only edits you need are the four values in the
CONFIG block at the top. It reads the mTLS client certificate from a
dwn-certs/ folder bundled alongside it (there are no environment variables at
the edge).
// index.js — Okta Token Inline Hook -> Darwinium, as a CloudFront Lambda@Edge function.
// Deploy as a VIEWER-REQUEST trigger with body inclusion enabled. The function
// generates the response itself; it never forwards to an origin.
// OPTIONS -> 204 + CORS (preflight)
// GET x-okta-verification-... -> 200 { verification: <challenge> } (Okta hook verify)
// POST /okta/token-hook -> retrieve Darwinium decision by state;
// 'reject' -> 200 { error: {...} } (blocks), else 200 { commands: [] }
const https = require('https');
const fs = require('fs');
const path = require('path');
// ------------------------------- CONFIG -------------------------------
const DWN = {
host: '<your-node>.node.darwinidentity.com', // Darwinium Event API host
port: 9443,
journey: '<journey>', // e.g. 'test'
};
const LOGIN_STEP = 'login'; // scored step; imports web_collect.PROFILING
const HOOK_HEADER = 'x-darwinium-hook'; // the auth header Okta sends (see A.3)
const HOOK_SECRET = '<shared-secret>'; // set '' to disable header verification
// ----------------------------------------------------------------------
const CERTDIR = path.join(__dirname, 'dwn-certs');
const TLS = {
key: fs.readFileSync(path.join(CERTDIR, 'privkey.key')),
cert: fs.readFileSync(path.join(CERTDIR, 'darwinium-signed.pem')),
// NOTE: no `ca`. The node's server cert is a public CA (Let's Encrypt), so Node
// verifies it against its built-in store. Passing the Darwinium client-CA chain
// here would replace the system store and break server verification.
};
const CORS = {
'access-control-allow-origin': '*', // tighten to your sign-in origin in production
'access-control-allow-methods': 'POST, GET, OPTIONS',
'access-control-allow-headers': 'Content-Type, dwn-profiling, ' + HOOK_HEADER,
};
const stepPath = s => `/api/event/${DWN.journey}/${s}`;
const dwnDecision = evt =>
evt && evt.outcome && evt.outcome.CHAMPION && evt.outcome.CHAMPION.decision_strategy
&& evt.outcome.CHAMPION.decision_strategy.result;
// Darwinium Event API call over mTLS.
function dwnCall(step, bodyObj, timeoutMs = 2000) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(bodyObj || {});
const req = https.request({
host: DWN.host, port: DWN.port, path: stepPath(step), method: 'POST',
key: TLS.key, cert: TLS.cert, timeout: timeoutMs,
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json',
'Content-Length': Buffer.byteLength(payload) },
}, r => {
const c = []; r.on('data', d => c.push(d));
r.on('end', () => { try { resolve(JSON.parse(Buffer.concat(c).toString())); } catch (_) { resolve(null); } });
});
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.on('error', reject);
req.write(payload); req.end();
});
}
// CloudFront helpers. Header keys are lowercased and values wrapped in arrays.
const hdr = (headers, name) => {
const h = headers && headers[name.toLowerCase()];
return h && h[0] ? h[0].value : null;
};
function jsonResponse(status, obj, extraHeaders = {}) {
const headers = { 'content-type': [{ key: 'Content-Type', value: 'application/json' }] };
for (const [k, v] of Object.entries({ ...CORS, ...extraHeaders })) {
headers[k.toLowerCase()] = [{ key: k, value: v }];
}
return { status: String(status), statusDescription: 'OK', headers, body: JSON.stringify(obj) };
}
function readBody(request) {
if (!request.body || request.body.data == null) return '';
return request.body.encoding === 'base64'
? Buffer.from(request.body.data, 'base64').toString('utf8')
: request.body.data;
}
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
const { method, uri } = request;
const headers = request.headers || {};
// CORS preflight
if (method === 'OPTIONS') {
const h = {}; for (const [k, v] of Object.entries(CORS)) h[k] = [{ key: k, value: v }];
return { status: '204', statusDescription: 'No Content', headers: h, body: '' };
}
// Okta hook one-time verification handshake
const challenge = hdr(headers, 'x-okta-verification-challenge');
if (method === 'GET' && challenge) return jsonResponse(200, { verification: challenge });
let body = readBody(request); try { body = JSON.parse(body); } catch (_) {}
// Token inline hook
if (uri.startsWith('/okta/token-hook') && body && body.data) {
// Optional: reject calls that do not carry the shared secret.
if (HOOK_SECRET && hdr(headers, HOOK_HEADER) !== HOOK_SECRET) {
return jsonResponse(401, { error: { errorSummary: 'unauthorized' } });
}
const state = body.data.context?.protocol?.request?.state || null;
const login = body.data.context?.session?.login
|| body.data.identity?.claims?.preferred_username || null;
try {
// The `login` step imports the profiling collected at /__collect; add the identity here.
const identity = login ? { ACCOUNT: { username: { username: login } } } : {};
const evt = await dwnCall(LOGIN_STEP, { primary_session_tie: state, identity }, 2000);
if (dwnDecision(evt) === 'reject') {
return jsonResponse(200, { error: {
errorSummary: 'Access denied — elevated risk (Darwinium)',
errorCauses: [{ errorSummary: 'Darwinium decision: reject', reason: 'high_risk' }],
} });
}
} catch (e) {
console.log('[DWN] fail-open:', e.message); // timeout/error -> allow (token issued)
}
return jsonResponse(200, { commands: [] });
}
return jsonResponse(200, { ok: true });
};
C.3 Package the function
A skeleton bundle (index.js, a dwn-certs/ folder with placeholder cert
files, and a README) is provided as darwinium-okta-edge-hook.zip. Put your own
Darwinium mTLS client certificate and key in the dwn-certs/ folder — replacing
the placeholders — then zip:
mkdir -p dwn-certs
# replace the placeholders with YOUR real Darwinium client cert + key:
# dwn-certs/darwinium-signed.pem dwn-certs/privkey.key
zip -j function.zip index.js
zip function.zip dwn-certs/privkey.key dwn-certs/darwinium-signed.pem
### ⚠️ The mTLS key is a private credential
privkey.keyis your Darwinium client private key. Never commit it to
source control, never share afunction.zipthat contains a real key, and
rotate it if it is ever exposed. The bundle ships with placeholder files
precisely so no key material travels with the guide.
C.4 Create the IAM execution role
Lambda@Edge requires a role trusted by both lambda.amazonaws.com and
edgelambda.amazonaws.com:
cat > trust.json <<'JSON'
{ "Version": "2012-10-17", "Statement": [{
"Effect": "Allow",
"Principal": { "Service": ["lambda.amazonaws.com", "edgelambda.amazonaws.com"] },
"Action": "sts:AssumeRole" }] }
JSON
aws iam create-role --role-name okta_dwn_edge_role \
--assume-role-policy-document file://trust.json
aws iam attach-role-policy --role-name okta_dwn_edge_role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
C.5 Create the function and publish a version
The function must be created in us-east-1, and CloudFront can only
associate a numbered version, never $LATEST:
aws lambda create-function --region us-east-1 \
--function-name okta_dwn_token_hook \
--runtime nodejs20.x --handler index.handler \
--role arn:aws:iam::<aws-account-id>:role/okta_dwn_edge_role \
--timeout 5 --memory-size 128 \
--zip-file fileb://function.zip
# Publish a numbered version and note the value it prints (e.g. 1):
aws lambda publish-version --region us-east-1 \
--function-name okta_dwn_token_hook --query 'Version' --output text
To ship a later change, re-zip, aws lambda update-function-code, then
publish-version again and re-point the behaviour at the new version.
C.6 Attach it to a CloudFront behaviour
On the distribution that serves dwn_profiling.js, add a behaviour for the hook
path and associate the function as a viewer-request trigger with Include
body enabled (so the handler can read the POST body). Repeat the association
pattern for the /__collect path if you serve the collector from the same
function.
| Behaviour setting | Value |
|---|---|
| Path pattern | /okta/token-hook (and /__collect) |
| Viewer protocol policy | HTTPS only |
| Allowed methods | GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE |
| Cache policy | Managed-CachingDisabled |
| Function association | Lambda@Edge, event type Viewer request, Include body: yes, ARN = …:function:okta_dwn_token_hook:<version> |
Via the CLI, pull the config, add the behaviour to
DistributionConfig.CacheBehaviors.Items (with
LambdaFunctionAssociations → EventType: viewer-request, IncludeBody: true,
the versioned ARN), increment CacheBehaviors.Quantity, and
aws cloudfront update-distribution --id <dist-id> --if-match <etag>.
mTLS certificate gotcha. Darwinium node server certificates are issued by a
public CA (Let's Encrypt). In Node'shttpsclient, pass only the client
key+cert; do not pass Darwinium's client-CA chain asca— that
replaces the system trust store and breaks server verification with "unable to
get local issuer certificate".
Naming. In a shared Darwinium sandbox account, do not prefix edge
resources withdwn_— a housekeeping process sweepsdwn_*and will delete
the function and strip its CloudFront behaviour. Use anokta_dwn_*name.
Teardown. A Lambda@Edge function cannot be deleted until CloudFront drains
all edge replicas — hours after you detach the behaviour. Deletes report
DELETE_FAILED/ replicated-function errors until then; that is expected.
C.7 Smoke-test the endpoint
Once CloudFront reports Deployed:
BASE=https://<your-cloudfront-domain>/okta/token-hook
# Okta hook verification handshake
curl -s -H 'x-okta-verification-challenge: ping' "$BASE"
# -> {"verification":"ping"}
# A minimal hook payload with a state
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"data":{"context":{"protocol":{"request":{"state":"smoke"}}}}}' "$BASE"
# -> {"commands":[]}
7. The sign-in flow

- The user opens the Okta-hosted sign-in page.
dwn_profiling.jsloads and
profiling starts; the page reads the login'sstate. - The user submits the password form. The capture-phase listener collects the
profiling blob and posts it to/__collect?primary_session_tie=<state>.
Darwinium'sweb_collectstep storesPROFILINGkeyed bystate. - Okta authenticates the credentials and moves to mint the token on the custom
authorization server. - The token mint fires the Token Inline Hook. Okta POSTs the payload (carrying
state) to/okta/token-hook. - The edge function calls Darwinium's
loginstep with
primary_session_tie = stateand the username. Darwinium resolves the same
journey, surfaces the profiling from step 2, and the Decision Strategy scores
it. - The hook returns the decision:
{ commands: [] }to issue the token, or
{ error: {…} }to block. On timeout or error, Okta issues the token
(fail-open).
Timing race. The hook needs the profiling event already computed under the
tie. Profiling posts at submit; the hook waits within its ~2s budget for the
imported dependency. Keep both inside budget — a slow collect POST that lands
after the hook resolves means the decision sees no profiling.
8. Verification
A successful login proves nothing on its own — the hook fails open, so a
login also succeeds when Darwinium saw nothing. Check both that the decision
reached Okta and that Darwinium actually scored the journey.
Confirm the login flows through the app
Reports → System Log, filtered to your OIDC app, shows real sign-ins hitting
the custom authorization server (User login, sign-on policy evaluation, identity
verification):

Confirm Darwinium scored the journey
In the Darwinium portal, Investigations over the relevant window shows the
test journey's events — the web_collect ingests and the scored login
events, with dispositions and a device map. This is the authoritative proof that
profiling was captured and a decision was produced under each state:

Force a decision end-to-end
- To confirm allow: sign in normally; a token is issued and the hook returns
{ commands: [] }. In the edge logs (CloudWatch) you will see
decision=pass. - To confirm reject: add a condition to the
loginDecision Strategy that
matches your test identity and emitsreject, then sign in. The token mint
fails and the edge log showsdecision=reject.
Token Preview does NOT fire inline hooks. Okta's Token Preview only
simulates claims, scopes and policy. The hook fires only on a real token mint
(an actual login, or a fullauthorization_code→/tokenexchange). Do not
use Token Preview to test the hook.
9. Operating this in production
The hook is fail-open — decide whether that is acceptable
On any timeout or non-200, Okta issues the token. This is safe against outages but
means the hook cannot be your only line of defence for high-assurance flows. If
you need a hard gate, place Darwinium's edge decisioning directly in the request
path.
Keep decision latency inside Okta's budget
The hook must return within ~3s or Okta abandons it (and issues the token). The
Darwinium call carries a ~2s timeout and fails open on its own. Watch p95 on the
edge function and on the Darwinium login step.
Certificate rotation
Darwinium Event-API mTLS certificates are valid for one year; rotate every 6–9
months. Because the certificate is baked into the Lambda@Edge zip, rotation means
redeploying a new function version and repointing the CloudFront behaviour.
Never cache the hook or collect responses
The token-hook and /__collect behaviours must use a caching-disabled policy.
Caching a per-login decision or profiling response would apply one user's outcome
to another.
Correlation is stateless
state is read from each hook payload and passed to Darwinium as
primary_session_tie. The journey state lives in Darwinium, not in the
middleware — the edge function holds no session.
10. Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
| Hook never fires | Token minted by the org server, or the hook not bound to the custom server's access-policy rule | Mint from the custom server (A.1); bind the hook (A.4) |
| Hook never fires, and you were testing with Token Preview | Token Preview does not call hooks | Drive a real login or a full authorization_code exchange |
| Sign-in page renders but no profiling, no console logs | A <script> is missing nonce="{{nonceValue}}" and CSP blocked it silently |
Add the nonce to every injected script |
Refused to connect / Failed to fetch in the console |
The host is not in the CSP trusted external resources | Add the profiling and collect hosts (A.7) |
| Collect POST rejected by the browser (CORS) | access-control-allow-origin ≠ your sign-in origin, or dwn-profiling not in allow-headers |
Match the origin exactly; allow the dwn-profiling header |
| Decision always allows, even for a risky user | Fail-open: the hook timed out, errored, or the login step saw no profiling |
Check edge logs for fail-open; confirm the collect POST lands before the hook resolves |
Custom /authorize parameter missing in the payload |
Custom params are dropped from protocol.request |
Use state, or parse data.context.request.url.value |
| Edge function or its CloudFront behaviour vanished | Named with a dwn_ prefix in a shared sandbox and swept |
Rename to okta_dwn_* and redeploy |
| mTLS call fails "unable to get local issuer certificate" | Darwinium client-CA chain passed as ca |
Pass only client key + cert; let Node use its system store |
11. Appendix: parameters
Replace throughout:
| Placeholder | Meaning | Example shape |
|---|---|---|
<your-okta-org> |
Okta org / issuer host | integrator-000000.okta.com |
login.<your-domain> |
Okta custom sign-in domain | login.example.com |
<your-cloudfront-domain> |
Edge distribution serving profiling + hook + collect | d….cloudfront.net |
<your-node> |
Darwinium node hostname | <node>.node.darwinidentity.com |
<your-host-alias> |
Journey host alias for the edge target | sandboxhost |
<journey> |
Darwinium journey name | test |
| profiling hosts | Darwinium profiling collection hosts to trust in CSP | aps.<node>, ats.<node>, *.dxf.<node> |
Correlation reference
Value on /authorize |
Read on the sign-in page | Read in the hook payload |
|---|---|---|
state (recommended tie) |
OktaUtil.getRequestContext().authentication.request.state |
data.context.protocol.request.state |
nonce |
same requestContext object | data.identity.claims.nonce |
custom param (e.g. dwn_tie) |
new URLSearchParams(location.search).get('dwn_tie') |
data.context.request.url.value (raw URL — parse it) |
Also present in the hook with no parameter: data.context.session.login
(username), data.context.session.id, data.context.request.id,
data.context.request.ipAddress, and data.identity.claims.preferred_username /
email (with the profile scope).
Reference
- Okta — Token inline hook
- Okta — Inline hooks concept
- Okta — Style the Okta-hosted sign-in page
- Darwinium — Tags Deployment
- Darwinium — Event API
Screenshots in this guide are from a reference deployment. Replace org names,
hostnames, client IDs and account identifiers with your own.