Skip to main content
← Back to blog

Implementing Risk based Adaptive Authentication

· 10 min read
O2ID Team
Maintainers

Not every login deserves the same treatment. A password typed in from the same laptop as always is one thing; the same password showing up from a country the user's never logged in from is another — and asking for MFA on every single sign-in just to catch the second case annoys everyone hitting the first.

O2ID's condition node can make simple calls like that, but only within limits: it evaluates a CEL expression with deliberately no loops, no variables, and no network access. A tenant admin writes the expression, and it has to be safe to evaluate blind every single time — keeping it that limited is what makes that safe. Real risk decisions rarely fit in one expression, though. "Is this login suspicious?" usually means asking an external fraud-scoring service, then weighing whatever it says back — a handful of signals, each worth something different, added up into a score. That needs a function, not a condition.

This post wires up a login flow that calls a fraud-check service through a webhook node, folds the answer into a step-up decision with a script node, and only asks for TOTP when the score says so.

What you'll build

A login flow with a risk gate:

  1. Password — same as any O2ID login.
  2. Webhook — calls a local "fraud-check" service with the resolved user's context. It answers with a base score and a list of signals like new_device or impossible_travel.
  3. Script — a few lines of Starlark that weigh each signal and decide: does this login need a second factor or not?
  4. Condition — routes accordingly: clean logins go straight through; flagged ones are asked for TOTP, enrolling on the spot if they haven't already.

Along the way you'll also deal with O2ID's SSRF guard: the fraud service runs on localhost, and O2ID refuses to call private/loopback addresses by default — on purpose — so the flow won't actually work until you explicitly allow that address.

Before you begin

Same baseline as the TOTP login flow post: O2ID running locally on http://localhost:8080, o2idctl already authenticated, jq installed, and an authenticator app on your phone. You'll also need Python 3 — no packages beyond the standard library — to run a five-line stand-in for a real fraud-scoring API.

Walkthrough

Step 1: Stand up a fake fraud-check service

Save this as risk_check_server.py:

#!/usr/bin/env python3
"""Toy fraud-check service for the O2ID webhook-node tutorial. Flags any
email starting with "risky-" as suspicious; everything else is clean."""
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

class RiskCheckHandler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
email = body.get("user", {}).get("attributes", {}).get("email", "")

if email.startswith("risky-"):
result = {"score": 20, "signals": ["new_device", "impossible_travel"]}
else:
result = {"score": 5, "signals": []}

print(f"risk-check: received payload: {json.dumps(body)}")
print(f"risk-check: {email!r} -> {result}")
print(f" X-O2ID-Signature: {self.headers.get('X-O2ID-Signature')}")

payload = json.dumps(result).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)

def log_message(self, *args):
pass # keep the terminal focused on the print() lines above

HTTPServer(("127.0.0.1", 8090), RiskCheckHandler).serve_forever()

Run it in its own terminal and leave it running for the rest of this post:

python3 risk_check_server.py

Every request O2ID's webhook node sends carries an X-O2ID-Signature header — an HMAC-SHA256 of the request body under a key derived from your tenant, so a real receiver can verify the call genuinely came from your O2ID instance and not an impersonator. This toy server just prints it; a production one would recompute and compare it before trusting the payload.

Note where email actually lives in that payload: O2ID never inlines a user's custom fields onto user directly — they're always under user.attributes, alongside the built-in user.username, user.enrolledFactors, and so on. That's why the handler above reads user.attributes.email, not user.email.

Step 2: Create a TOTP authenticator

This is the step-up factor for logins the risk check flags. --field issuer is what shows up before the colon in the user's authenticator app — set it to your own deployment's name instead of the default O2ID. --field progressiveEnrollment=true lets a flagged user with no verified enrollment yet get enrolled on the spot, instead of the step-up just rejecting their (nonexistent) code:

AUTH_JSON=$(o2idctl authenticators create --type totp --display-name "Company TOTP" \
--field issuer=Acme --field progressiveEnrollment=true)

AUTHENTICATOR_ID=$(printf '%s' "$AUTH_JSON" | jq -r .id)

The part after the colon is the signed-in user's username, filled in automatically at enrollment time — so this entry will read Acme:alice, not a generic Acme:O2ID user shared by every enrollee. --field accountName still exists as a fallback label for the rare case a flow enrolls someone before an identifier authenticator has resolved who they are.

Step 3: Write the flow definition

cat > risk-flow.json <<EOF
{
"start": "identify",
"nodes": {
"identify": {
"type": "authenticator",
"onSuccess": "password",
"onFailure": "identify",
"properties": {
"authenticatorId": "identifier"
}
},
"password": {
"type": "authenticator",
"onSuccess": "risk_check",
"onFailure": "password",
"properties": {
"authenticatorId": "password",
"maxAttempts": 5,
"onLockout": "deny"
}
},
"risk_check": {
"type": "webhook",
"onSuccess": "score_risk",
"onFailure": "totp",
"properties": {
"url": "http://127.0.0.1:8090/risk-check",
"timeoutMs": 3000
}
},
"score_risk": {
"type": "script",
"onSuccess": "mfa_gate",
"onFailure": "totp",
"properties": {
"source": "weights = {'new_device': 25, 'impossible_travel': 40, 'tor_exit_node': 60}\nsignals = step['risk_check']['result']['signals']\ntotal = step['risk_check']['result']['score']\nfor s in signals:\n total += weights.get(s, 10)\ntotal = min(total, 100)\nresult = {'score': total, 'requireStepUp': total >= 50}"
}
},
"mfa_gate": {
"type": "condition",
"branches": [
{ "if": "step.score_risk.result.requireStepUp", "then": "totp" }
],
"else": "allow"
},
"totp": {
"type": "authenticator",
"onSuccess": "allow",
"onFailure": "totp",
"properties": {
"authenticatorId": "$AUTHENTICATOR_ID",
"maxAttempts": 5,
"onLockout": "deny"
}
},
"allow": { "type": "outcome", "properties": { "result": "allow" } },
"deny": { "type": "outcome", "properties": { "result": "deny" } }
}
}
EOF

Three things worth pointing out:

  • risk_check's onFailure skips straight to totp, not deny. If the fraud service times out, is down, or gets blocked (more on that in a moment), the flow fails closed — it asks for a second factor rather than either denying every login outright or, worse, silently skipping the risk check and letting everyone through. score_risk does the same on its own onFailure, for the same reason.
  • score_risk reads step.risk_check.result — the previous webhook step's parsed response — the same step.<nodeId>.result path a condition node would read, just holding whatever JSON shape the webhook or script actually returned instead of a plain success/failure string.
  • The for loop is why this is a script and not a condition. CEL can't iterate an open-ended signal list and accumulate a score — it's deliberately not that kind of language. Starlark is: this reads like an ordinary function body, no def/call boilerplate needed, because a script node's source runs as a short imperative program, not a BUILD-style config file.

risk_check's url points at 127.0.0.1 — a loopback address — and O2ID's webhook node refuses to connect to private, loopback, and link-local addresses by default, regardless of what properties.url says. That's deliberate: it stops a flow author from using a webhook to reach internal infrastructure the tenant never explicitly allowed. Allow this one address before going any further, or every login will fail closed to totp instead of ever reaching a risk score:

o2idctl tenant-settings update --webhook-allowlist 127.0.0.1

127.0.0.1 now bypasses the address check entirely for webhook nodes in this tenant. Nothing else does — this is an allowlist, not a kill switch.

Check the definition before it's ever stored:

o2idctl flows validate --definition-file risk-flow.json

Then create and activate it:

FLOW_JSON=$(o2idctl flows create --name "Risk-Based Adaptive Authentication" --trigger login --definition-file risk-flow.json)
FLOW_ID=$(printf '%s' "$FLOW_JSON" | jq -r .id)
o2idctl flows activate "$FLOW_ID"

validate checks that every node reference resolves, every referenced authenticator ID exists, and every node is reachable. activate makes it the flow that actually runs for the login trigger.

Step 4: Create an OAuth client

APP_JSON=$(o2idctl applications create --name "Risk Flow Demo" \
--callback-url https://oidcdebugger.com/debug --trusted)

CLIENT_ID=$(printf '%s' "$APP_JSON" | jq -r .clientId)
TENANT=system # replace with your own tenant's slug, if you have one

Print the Authorize URI you'll need, with $TENANT already substituted:

echo "http://localhost:8080/t/$TENANT/oauth2/authorize"

Use oidcdebugger.com with this authorize URI, $CLIENT_ID, redirect URI https://oidcdebugger.com/debug, scope openid, response type code, and PKCE enabled.

Step 5: Create two test users

The built-in user type's login identifier is username, a separate, format-restricted field (letters, digits, dots, dashes, underscores — no @) from the optional email attribute the risk check actually reads. Set both: username is what you'll sign in with, email is what risk_check_server.py inspects to decide who looks risky.

o2idctl users create --attribute username=alice --attribute email=alice@example.com --password Str0ngPassw0rd9
o2idctl users create --attribute username=risky-bob --attribute email=risky-bob@example.com --password Str0ngPassw0rd9

Nobody's fraud-worthy on paper — the fake service decides purely by the email attribute's prefix, so you can reliably trigger either path just by which user you sign in as. Neither user has TOTP enrolled yet, so whichever one first reaches totp gets progressively enrolled instead of asked for a code it can't yet have.

Step 6: Sign in as Alice

Start the authorize request and sign in as alice / Str0ngPassw0rd9. Watch the risk_check_server.py terminal:

risk-check: 'alice@example.com' -> {'score': 5, 'signals': []}
X-O2ID-Signature: 3f7a1c...

The request arrived, signed, and came back clean. Back in the browser: score_risk computed requireStepUp: false from a score of 5, mfa_gate sent her straight to allow, and you land back on oidcdebugger.com with an authorization code — no TOTP screen at all. Exchange it the same way as before (http://localhost:8080/t/$TENANT/oauth2/token).

Step 7: Sign in as risky-bob

Start a fresh authorize request and sign in as risky-bob / Str0ngPassw0rd9. The server logs:

risk-check: 'risky-bob@example.com' -> {'score': 20, 'signals': ['new_device', 'impossible_travel']}

score_risk adds 20 + 25 (new_device) + 40 (impossible_travel) = 85, well past the 50-point threshold — requireStepUp: true. You land on the TOTP enrollment screen this user actually needs. Scan the code, verify, and you're in.

Sign in as risky-bob once more and you'll see a plain 6-digit code field instead — enrolled now, the totp node just verifies a code rather than enrolling him again.

What you learned

  • A webhook node's response becomes step.<nodeId>.result for later nodes to read — just like an authenticator's outcome, except it can be any JSON shape the endpoint returns, not just a status string.
  • A script node runs real Starlark — loops, reassignment, arithmetic — for logic a CEL condition genuinely can't express, and its return value works the same way.
  • Routing a webhook or script's onFailure to a safe fallback (here, "require step-up") is a deliberate fail-closed choice, not a default you get for free.
  • O2ID's SSRF guard blocks private/loopback/link-local addresses for webhook nodes unconditionally, and o2idctl tenant-settings update --webhook-allowlist is how a tenant opts a specific host back in — on purpose, one host at a time.

To go further, see Flow Definition Schema for the full script/webhook reference, including the security model behind both.