Implementing Risk based Adaptive Authentication
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:
- Password — same as any O2ID login.
- 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_deviceorimpossible_travel. - Script — a few lines of Starlark that weigh each signal and decide: does this login need a second factor or not?
- 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'sonFailureskips straight tototp, notdeny. 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_riskdoes the same on its ownonFailure, for the same reason.score_riskreadsstep.risk_check.result— the previous webhook step's parsed response — the samestep.<nodeId>.resultpath aconditionnode would read, just holding whatever JSON shape the webhook or script actually returned instead of a plainsuccess/failurestring.- The
forloop 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, nodef/call boilerplate needed, because ascriptnode'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
webhooknode's response becomesstep.<nodeId>.resultfor 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
scriptnode 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
onFailureto 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
webhooknodes unconditionally, ando2idctl tenant-settings update --webhook-allowlistis 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.