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.

Okta

Prev Next

# 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 as login.<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

  1. What this delivers
  2. How it works, and why it looks like this
  3. Before you start
  4. Part A — Okta configuration
  5. Part B — the Darwinium journey
  6. Part C — the edge middleware
  7. The sign-in flow
  8. Verification
  9. Operating this in production
  10. Troubleshooting
  11. 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 an error object
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 /authorize parameters do not survive cleanly. Only known OAuth
parameters land in data.context.protocol.request. A custom parameter (e.g.
dwn_tie) is dropped from protocol.request and survives only inside the
raw authorize URL at data.context.request.url.value, which you would have to
parse. Use state unless you have no usable state, 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:

Architecture — the browser profiling path and the Okta token-hook path both converge on the CloudFront edge and Darwinium, correlated by the OAuth state value

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.

Okta Authorization Servers list — the default custom server, issuer .../oauth2/default

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

Custom authorization server Settings — name, audience, issuer and metadata URI

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.

OIDC application General tab — SPA, PKCE required, Authorization Code grant, client ID

A.3 Register the Token Inline Hook

Workflow → Inline Hooks → Add Inline Hook → Token.

Inline Hooks list — the token hook, type Token, status Active

  • 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 delivered eventType is
    com.okta.oauth2.tokens.transform.
  • Set status Active.

Token Inline Hook detail — URL points at the edge endpoint, HTTP-header authentication

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.

  1. Add Policy and assign it to your app (or All Clients).
  2. Add Rule, include the Authorization Code grant, and set your user /
    scope conditions.
  3. In the rule, set "Use this inline hook" → select your token hook → save.

Access Policies on the custom server — the policy assigned to All Clients, with one rule

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:

Edit Rule — Authorization Code grant selected, THEN

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.

Brands list — the brand bound to the custom sign-in domain

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

Brand Domains tab — custom domain Active, Okta-managed 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.

Sign-in page code editor — the dwn_profiling.js script injected into the page head

<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 carry nonce="{{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 submit event. The third-generation Okta
Sign-In Widget (^7) does fire a real submit event, 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-profiling POST

Content Security Policy — trusted external resources: the CDN and Darwinium profiling hosts, enforcement Enforced

If a request is CSP-blocked you will see Refused to connect / Failed to fetch in 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.

The Darwinium journey in the Workflows editor — Tag Profiling Step, login, collect, web_collect, web_collect_options, token_process

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.

The web_collect proxy step — GET /__collect, event type misc_other

  • url_match_type: ExactWithQuery — the query string is part of the match.
  • The CORS access-control-allow-origin must equal your sign-in page origin
    (e.g. https://login.<your-domain>) or the browser rejects the response.
  • The blob rides in the dwn-profiling request 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 login API step — account_login, importing event data from web_collect, with Decision Strategy dispositions

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:

token_process step — a Proxy Step on POST /__token importing web_collect profiling

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

token_process Data Mappings — state → primary_session_tie, session.login → account 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

darwinium-okta-edge-hook.zip

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.key is your Darwinium client private key. Never commit it to
source control, never share a function.zip that 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
LambdaFunctionAssociationsEventType: 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's https client, pass only the client
key + cert; do not pass Darwinium's client-CA chain as ca — 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 with dwn_ — a housekeeping process sweeps dwn_* and will delete
the function and strip its CloudFront behaviour. Use an okta_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

Sign-in sequence — profiling is collected at password submit and stored under state; at token mint the hook retrieves the decision by state and allows or blocks

  1. The user opens the Okta-hosted sign-in page. dwn_profiling.js loads and
    profiling starts; the page reads the login's state.
  2. The user submits the password form. The capture-phase listener collects the
    profiling blob and posts it to /__collect?primary_session_tie=<state>.
    Darwinium's web_collect step stores PROFILING keyed by state.
  3. Okta authenticates the credentials and moves to mint the token on the custom
    authorization server.
  4. The token mint fires the Token Inline Hook. Okta POSTs the payload (carrying
    state) to /okta/token-hook.
  5. The edge function calls Darwinium's login step with
    primary_session_tie = state and the username. Darwinium resolves the same
    journey, surfaces the profiling from step 2, and the Decision Strategy scores
    it.
  6. 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):

Okta System Log — real sign-in events against the OIDC client

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:

Darwinium Investigations — journeys, dispositions and events for the test journey

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 login Decision Strategy that
    matches your test identity and emits reject, then sign in. The token mint
    fails and the edge log shows decision=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 full authorization_code/token exchange). 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


Screenshots in this guide are from a reference deployment. Replace org names,
hostnames, client IDs and account identifiers with your own.