Use the OAuth login for exactly one job — proving which human is at the keyboard — and let device risk signals decide what that IoT console session is allowed to do once it exists. That split is the load-bearing decision here, and it is also what makes a migration off a managed identity provider survivable: the identity half is a protocol you can re-point at a different issuer, while the risk half is data your own fleet already produces. Combining both into a single vendor-evaluated login verdict is how teams end up with authorization rules they cannot export.
The system I'll keep referring to is a customer support desk. Agents sign in with email and password, open a device page, and from that page they can pull logs, push a config, reboot a unit, or start a firmware rollback on hardware sitting in someone else's building. The provider handling that sign-in is on its way out, and the bill for the move is not the login form. It's everything that only exists inside the provider's dashboard: impossible-travel rules, device fingerprints, allow-lists, the audit trail an auditor will ask for.
Write that inventory down before you touch anything.
Invariants worth fixing before the login moves
Four properties keep this design honest, and each one maps to a failure boundary you can test.
- Authentication answers who, once per session. Authorization answers what, on every request that changes state.
- Risk signals are inputs to authorization, never a veto on authentication. An agent locked out of the console at 02:00 because a laptop rebooted onto a hotel network is an outage you caused.
- Every state-changing action carries the authentication context that permitted it — the
acrandamrclaims plusauth_timefrom the OpenID Connect ID token, copied into the audit record. - Each signal has a declared behaviour when its source is unavailable. Read-only actions fail open with reduced scope; destructive actions fail closed.
That fourth one is where most designs are quietly undefined. A risk service that returns nothing looks identical to a risk service that returns "low" if you wrote if risk.score > 60 and nothing else, so a five-minute outage in the scoring path silently promotes every session to trusted. I've seen the same shape in OTP delivery: a queue backs up, the verification step returns no result, and the code path treats "no answer yet" as "not a problem". Declare staleness explicitly, with a max age in seconds, and make the destructive branch demand a fresh verdict.
There's a second conflation specific to fleet consoles. "Device risk" means two different devices — the agent's laptop or phone, and the IoT unit being operated on. A managed laptop on a corporate network operating a device in a hospital is not the same risk as an unmanaged browser touching a warehouse sensor. Keep them as separate fields all the way through, because the day you need a rule like "unmanaged agent device may not touch medical-class units", a single fused score can't express it.
How should device risk signals change what an OAuth login can do in the console?
By raising the assurance required for an action, not by rejecting the login. OAuth 2.0 gives you the vocabulary for this already: short-lived access tokens, scopes narrow enough to describe one class of action, and a documented way to ask for more.
The mechanism is RFC 9470. When a request arrives with a valid token that doesn't meet the assurance the action needs, the resource server answers 401 with error="insufficient_user_authentication" plus acr_values and max_age parameters, and the client sends the user back through the authorization endpoint carrying those requirements. The re-authentication is the same protocol flow you already run, just parameterised. Nothing about it is vendor-specific, which is the point: it survives the issuer swap.
Sender-constrained tokens are the other half. A bearer token lifted from a support agent's browser works anywhere; a token bound to a key held by that browser, per RFC 9449, does not. For a console that can brick hardware, that binding is worth the extra key handling.
Now, the part I'd argue about with anyone: what you use as the step-up factor. Not an emailed code. I've spent enough time on deliverability to distrust anything that has to cross a mail gateway during an incident — greylisting adds minutes, corporate filters quarantine anything that looks templated, and an agent halfway through a rollback does not have minutes. SMS carries its own baggage; NIST's guidance has treated out-of-band verification over the public telephone network as restricted since the 800-63B revision, on the strength of the SIM-swap and interception record. A hardware-backed WebAuthn credential — an authenticator the agent already has because it's how they sign in — answers in under a second and never touches a mail server. Use OTP as the fallback for the account recovery desk, and mark those sessions as recovered so the console can refuse destructive scopes on them.
One nuance I'm not certain about: whether continuous evaluation is worth the complexity at small fleet sizes. Re-checking risk mid-session catches a stolen laptop that was clean at login. It also means an agent can lose a session in the middle of a config push, and I've not seen convincing evidence about where that trade lands below a few hundred seats.
Three migration shapes, and what each one actually costs
| Path | What you own | Cost profile | Where it hurts |
|---|---|---|---|
| Keep the managed provider, keep its risk engine | Nothing new | Lowest today | Risk rules and audit history live somewhere you can't re-point |
| Cut over to a self-hosted OIDC provider | Token service, signing keys, database, upgrades | Highest, and concentrated in one release | You're on call for the login path; the password hash export has to be negotiated first |
| Keep an external issuer for identity, own session and risk | Session store, policy engine, signal pipeline | Spread across several releases | Two moving parts instead of one, and you write the step-up UX yourself |
Self-hosted stacks such as Keycloak and Ory Kratos put the token service inside your cluster, which relocates patching, key rotation and database backups onto your team — a real cost, not a hidden one. Hosted platforms such as Auth0 and Okta run that surface for you and expose their risk verdicts through their own extension points, which is fine right up until the verdicts are the thing you need to take with you. Neither statement is a knock on the products. They're the boundaries you're choosing between.
The email-and-password half of the move deserves a note, since it's usually the reason cutovers get postponed. Hash formats travel badly. If the old provider exports hashes at all, they arrive in whatever scheme it used, and the sane path is to accept both schemes and upgrade on first successful sign-in:
def verify_and_upgrade(user, password, store):
"""Accept the imported scheme once, then rehash to the current one."""
if user.hash_scheme == "imported-bcrypt":
if not bcrypt.checkpw(password.encode(), user.hash):
return False
store.set_hash(user, argon2.hash(password), scheme="argon2id")
return True
return argon2.verify(user.hash, password)
Agents who don't sign in during the window keep the imported scheme until they do, so keep the old verifier until the count of legacy rows reaches zero, then delete the branch.
The critical path, in code
The authorization decision is small enough to read in one sitting, which is the property you want in the function that guards a factory reset:
DESTRUCTIVE = {"device.reboot", "device.firmware_rollback", "device.factory_reset"}
# acr values are deployment-defined; this one means a hardware-backed WebAuthn credential.
ACR_HARDWARE = "urn:example:acr:webauthn-hardware"
FRESH_AUTH_SECONDS = 900
RISK_MAX_AGE_SECONDS = 300
def authorize(action, claims, risk, now):
"""claims: verified OIDC ID token. risk: latest fleet verdict, or None."""
if action not in DESTRUCTIVE:
if risk is None or risk.age(now) > RISK_MAX_AGE_SECONDS:
return "allow", {"scope": "device.read"} # degrade, don't lock out
return "allow", {"scope": "device.read device.config"}
if risk is None or risk.age(now) > RISK_MAX_AGE_SECONDS:
return "deny", {"reason": "no fresh risk verdict"}
stale_auth = now - claims["auth_time"] > FRESH_AUTH_SECONDS
weak_auth = claims.get("acr") != ACR_HARDWARE
elevated = risk.score > 60 or risk.agent_device_unmanaged or risk.unit_class == "regulated"
if stale_auth or weak_auth or elevated:
return "step_up", step_up_challenge(acr=ACR_HARDWARE, max_age=0 if elevated else FRESH_AUTH_SECONDS)
return "allow", {"scope": "device.read device.config device.write"}
def step_up_challenge(acr, max_age):
"""RFC 9470 challenge, returned with HTTP 401."""
return {
"WWW-Authenticate": (
'Bearer error="insufficient_user_authentication", '
f'acr_values="{acr}", max_age={max_age}'
)
}
Two details carry most of the weight. risk.age(now) makes staleness a first-class condition rather than an implicit one. And the destructive branch never falls through to allow — every path out of it is deny, step_up, or an explicit grant.
Log the inputs, not just the verdict. When an auditor asks why an agent could reset a unit last Tuesday, "allowed" is not an answer; the acr value, the auth_time, the risk score and its age are.
The option I rejected, and when it's the right call
I'd argue against the single-release cutover to a self-hosted stack for a support desk of this size, and the reason is scope, not quality. That move changes the token issuer, the password store, the session semantics and the risk pipeline at once, and when agents can't sign in on Monday morning you have four suspects. Splitting it — issuer first, then sessions, then risk — costs a few extra weeks of running two paths, and buys you a bisectable failure.
The catch is that this advice inverts under two conditions. If data residency or an air-gapped deployment means no external issuer is permitted, the self-hosted path isn't a preference, it's the only compliant shape, and staging it through a provider you'll delete anyway is wasted work. The second condition is scale: below roughly twenty internal users with no customer-facing tenancy, a risk pipeline is machinery without a job, and you should stick with the managed provider's built-in controls until the fleet or the compliance surface grows into it.
What doesn't change is the invariant at the top. Login proves the person; risk decides the blast radius. Get those on separate wires and the next migration is an issuer swap instead of a rewrite.
References
- OWASP Authentication Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- RFC 9700, Best Current Practice for OAuth 2.0 Security — https://www.rfc-editor.org/rfc/rfc9700.html
- RFC 9470, OAuth 2.0 Step Up Authentication Challenge Protocol — https://www.rfc-editor.org/rfc/rfc9470.html
- RFC 9449, OAuth 2.0 Demonstrating Proof of Possession (DPoP) — https://www.rfc-editor.org/rfc/rfc9449.html
- RFC 8693, OAuth 2.0 Token Exchange — https://www.rfc-editor.org/rfc/rfc8693.html
- OpenID Connect Core 1.0, acr and amr claims — https://openid.net/specs/openid-connect-core-1_0.html#IDToken
- NIST SP 800-63B, Authenticator and Verifier Requirements — https://pages.nist.gov/800-63-3/sp800-63b.html
- OWASP Password Storage Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
Top comments (0)