This is the story of a support ticket that became an architecture review, and an architecture review that became a three-week proof of concept. It's written the way it actually happened including the parts where our first idea was, on reflection, a bad one.
It started as a one-line Slack message
Every engagement has an origin story, and ours is unglamorous. It was a Tuesday, and the message came from our account lead who works directly with Cloud Knowledge's IT Service Management team:
"Hey: can we do something in Okta where users recover their account with their password instead of email? Apparently half our reset tickets are people who don't remember what email address they registered with. They all remember their phone number though. Email should be optional. Client wants this yesterday." Slack, #cloudknowledge-iam, 9:14 AM
If you've worked in workforce or customer identity for more than a few months, you already felt two things reading that message. First, sympathy: this is an extremely common, extremely real problem. Second, a small alarm bell, because "recover your account with your password" is a sentence that doesn't parse the way it's meant to. You can't recover access to an account using the very credential you've lost. That's not pedantry; it's the whole reason recovery flows exist.
But we've learned not to take a one-line feature request at face value, good or bad. The words a client uses in a Slack message are rarely the actual requirement: they're a compressed, slightly garbled transmission of a real, specific pain that somebody on their helpdesk is feeling five times a day. Our job in the first 48 hours of any request like this is decompression: figure out what they actually need, not what they typed.
So before we touched a single toggle in the Okta Admin Console, we did what we always do: we asked for the ticket data.
What the helpdesk logs actually said
Cloud Knowledge is a mid-size professional services organization: roughly 6,200 employees, a large percentage of them field-based consultants, technicians, and site staff who are not sitting at a laptop all day. Many of them were provisioned Okta accounts as part of a broader HR and device modernization project eighteen months prior. Their primary device, for a large chunk of this population, is a company or personal mobile phone. Some of them touch a corporate email inbox once a week, if that.
We pulled three months of ServiceNow tickets tagged account-recovery and password-reset, and a pattern jumped out immediately:
- 41% of recovery tickets were closed with the note "user did not know registered email/username."
- 27% were standard "forgot password, reset link worked fine": the boring, already-solved case.
- 18% involved a user who had access to their phone number but not to the email inbox tied to their Okta profile (often a personal email set up years ago, migrated jobs, or an alias they never use).
- 14% were miscellaneous: locked accounts, MFA device lost, factor enrollment issues.
So the underlying, real requirement was not "let people type their password to get back into their password." It was: a meaningful chunk of this user population can reliably identify themselves by phone number, and struggles to identify themselves by email or username. The recovery experience needed to be re-anchored around a factor people actually remember.
"Give users the option to give password as a value to recover their account, email optional" became, after triage: "Allow self-service account recovery using a verified mobile phone number as the primary identifier, with email as a secondary/optional identifier, without weakening the security of the reset itself." That's the brief we actually built against, and it's the brief this article is about.
We want to be upfront about that translation, because it's the most important decision we made in the entire engagement, and it happened before we opened Okta at all. A lot of what follows: the architecture, the POC, the failure modes we hit: only makes sense in light of that reframing. So let's walk through how we got there, and then how we built it.
Why "recover with your password" can't be the literal design
Before we get into architecture, it's worth spending real time on why we pushed back on the literal ask, because we think this is the part other teams skip, and it's the part that determines whether the resulting solution is a security incident waiting to happen or a genuinely good user experience.
The circular logic problem
A password reset or account recovery flow exists for exactly one scenario: the user cannot authenticate with their normal credential. If a user has forgotten, lost, or been locked out of their password, then by definition they cannot supply that password as proof of identity during recovery: there's nothing to check it against that isn't itself the thing they've lost access to. Any flow that appears to let someone "recover with their password" is actually doing one of a few things under the hood, and each of them has very different security implications:
| What it looks like | What's actually happening | Security implication |
|---|---|---|
| User types their old/current password to "recover" the account | They already know the password: this isn't recovery, it's a login, possibly followed by a forced factor re-enrollment | Fine, but it's not solving the "forgot my identifier" problem at all |
| User types any password and the system accepts it as a recovery factor | The system is not actually verifying a secret: it's accepting arbitrary input as if it were proof of identity | Critical vulnerability: this is account takeover as a feature |
| User is prompted to set a new password as part of proving who they are | Setting a new secret isn't authentication: anyone, including an attacker, can "set a new password" | Also account takeover, just with extra steps |
We've seen variations of that second and third pattern shipped by well-meaning teams before, usually because a legacy on-prem system did something similar and nobody has re-examined it since. It's a classic "it's always been like this" vulnerability. We were not going to build Cloud Knowledge a new one, no matter how the original request was phrased.
Any flow where possession of a secret is not actually verified: where the system just accepts a claimed value without checking it against something the legitimate user alone could produce: collapses the entire security model of the identity provider. Okta, like every credible IdP, is built around the idea that recovery must re-establish identity through a factor independent of the one that was lost. We were not willing to design around that principle, and we said so on the call, with the ticket data as backup for a better alternative.
The conversation that actually mattered
We scheduled a 30-minute call with Cloud Knowledge's IT Service Delivery Manager and one architect from their security team. We didn't lead with "no." We led with the ticket breakdown from section one, and then asked a single question: "When your users say they can't remember their email, is it the address itself they've forgotten, or is it that they've never had reliable access to that inbox from the device they're standing in front of?"
The answer, after some back and forth, was mostly the latter. Many of their field staff had been set up with a corporate email alias during onboarding, used it exactly once (to accept the offer letter and set an initial password), and never touched it again because their actual day-to-day identity, from their own point of view, was "the phone number I gave HR."
That reframed the whole ask for the client too. Nobody in that call actually wanted an insecure password-bypass. What they wanted was for phone number to become a first-class recovery identifier, on par with (or ahead of) email, because that's the credential their workforce actually retains. Once we had that alignment, the rest of the design fell into place much faster than we expected.
What we agreed to build instead
- Phone number (SMS, and voice as a fallback for areas with poor SMS delivery) becomes a primary recovery identifier, verified via a one-time passcode: a real, possession-based factor.
- Email remains supported but becomes optional at the identifier stage: users can look up their account by phone number alone, and the system resolves it to the correct Okta profile.
- Under no circumstance does the flow accept a password, a "security answer," or any user-supplied secret as sole proof of identity during recovery. Recovery is always gated behind possession of a registered, verified device (or, as a fallback, a live identity-verification step).
- The end of a successful recovery flow is always a forced password reset and, where policy requires it, re-enrollment of MFA: recovery proves who you are; it does not skip the "now set a new secret" step.
With that agreed, we moved into design. The next section walks through the architecture options we actually put on the whiteboard, including the ones we threw out.
Turning a Slack message into a requirements document
With the reframed brief in hand, we ran a proper requirements workshop: ninety minutes, three teams in the room (virtually): Cloud Knowledge IT Service Delivery, Cloud Knowledge Security/GRC, and our own IAM engineering pod. We used a fairly standard MoSCoW pass (Must / Should / Could / Won't) because it forces prioritization arguments to happen in the workshop rather than three weeks into the build.
| Priority | Requirement | Notes from the room |
|---|---|---|
| Must | Recover account using verified phone number only, no email required at lookup stage | This is the entire point of the engagement |
| Must | OTP delivered via SMS, with voice call as a fallback channel | Several regional offices reported poor SMS delivery on certain MVNOs |
| Must | No secret (password, security question, employee ID) is ever accepted as sole proof of identity in recovery | Non-negotiable, security team required this in writing |
| Must | Recovery always ends in a forced password reset | Standard Okta behavior, but had to be explicit for the design doc |
| Must | Full audit trail of recovery attempts, including failed lookups, in Okta System Log | Required by Cloud Knowledge's SOC 2 auditor |
| Should | Rate limiting / lockout after repeated failed recovery attempts per phone number and per IP | Brute-force and enumeration protection |
| Should | Users can self-register/verify a phone number proactively, not just during a recovery event | You can't recover with a phone number nobody set up in advance |
| Should | Graceful fallback to email-based recovery for the population that still prefers it | Don't remove existing capability, add to it |
| Could | Progressive profiling nudge to add a phone number at next login, for users who don't have one on file | Backlog item, not core to POC |
| Could | Manager or helpdesk-assisted recovery path with elevated verification for edge cases | Useful, deferred to phase 2 |
| Won't (v1) | Biometric or passkey-based recovery | Device population too mixed (BYOD + shared kiosks) for phase 1 |
| Won't (v1) | Fully bypassing MFA re-enrollment post-recovery | Explicitly rejected by security team |
Constraints we had to design around
A few facts about the environment shaped almost every downstream decision:
- Okta Identity Engine (OIE), not Classic. Cloud Knowledge had migrated from Okta Classic Engine roughly a year earlier, which mattered a lot: OIE's Identity Flows are what make a custom, phone-first recovery experience achievable without a bolt-on IDP.
- SMS/Voice via the built-in Okta phone factor was already licensed (Okta Adaptive MFA SKU included it), so we weren't starting a procurement conversation with a telephony vendor from scratch. A custom SMS provider (Twilio) was explored as an enhancement but not a hard requirement for the POC.
- Universal Directory profile already had a
primaryPhone/mobilePhoneattribute populated for roughly 74% of users via an HR feed (Workday to Okta provisioning). The remaining 26% would need a backfill campaign: a real, separate workstream we flagged early. - No dedicated Okta Workflows SKU at the start of the engagement. This became relevant later when we talk about the custom flow options we considered and, in one case, had to shelve.
- Multiple brands under one Okta org (three business units, each with its own custom domain and branded sign-in widget), so whatever we built had to be brand-aware, not a single hardcoded experience.
It would have been easy to let this workshop balloon into "let's also redesign MFA enrollment, SSO, and lifecycle management while we're in here." We kept the POC scoped hard to recovery. Every adjacent idea went into a backlog document instead of the sprint. This is the single biggest reason the three-week POC timeline held.
Four ways to build phone-first recovery in Okta: and why we picked one
Okta gives you more than one lever to pull for a problem like this, and the honest answer is that "which one is right" depends on your OIE feature entitlements, your appetite for custom code, and how much you're willing to touch the hosted Sign-In Widget versus building your own. We put four real options on the table.
Option A: Native self-service password reset, unmodified
Okta's out-of-the-box self-service recovery already supports phone (SMS/voice) as a recovery factor, alongside email, security question, and other configured authenticators, through the standard Identity Engine recovery flow and its associated Authenticator policies. In theory we could simply reorder authenticator priority, make phone a required/available authenticator, and mark email optional in the recovery policy.
In favor
Zero custom code. Fully supported by Okta, no ongoing maintenance burden, upgrades transparently with the platform.
Against
The native flow still asks for a username up front (typically email) before it will offer you a choice of recovery factor: it doesn't natively support "look me up by phone number alone" as the very first step, which was our actual Must-have.
Option B: Custom Identifier First flow using phone as username
Okta supports an "Identifier First" routing rule in the sign-in flow, and Universal Directory lets you designate an attribute other than email as the login/username, or maintain a secondary lookup index. We explored remapping login to the phone number outright.
In favor
Very clean mental model: phone number becomes the actual username, so recovery-by-phone is just recovery, no special casing.
Against
Too invasive for a phase-1 POC: it changes the primary key of every account, touches SSO app assignments, breaks existing integrations that reference login as email, and is functionally a re-platforming project, not a recovery-flow project. Parked for a future phase.
Option C: Custom recovery experience via Okta's hosted widget customization + Inline Hooks
This is the option we ultimately built. Okta's Identity Engine lets you customize the Sign-In Widget's recovery flow presentation, combine it with a Registration Inline Hook and a Password Import Inline Hook-style pattern (repurposed for lookup logic), and layer in a lightweight external service that resolves "phone number to Okta user" before handing control back to Okta's native, secure OTP verification and reset machinery.
In favor
Keeps all secret verification: the actual OTP check and password reset: inside Okta's own secure recovery machinery. Our custom code only ever does identification (which account are we even talking about), never authentication. That's the correct boundary.
Against
Requires an external, lightweight lookup service (we used a small serverless function) and careful hook design so it can never be abused for account enumeration.
Option D: Third-party CIAM layer in front of Okta
Put a customer-identity product or a fully custom Node/Express app in front of Okta entirely, handling phone lookup and OTP ourselves, then federating into Okta once identity is established.
In favor
Maximum flexibility, no dependency on Okta's recovery primitives at all.
Against
Reinvents a huge amount of what Okta already does well and certifies (OTP generation, rate limiting, audit logging, SOC 2 controls). Also a much bigger licensing and maintenance commitment for a problem that didn't need it. Rejected.
We chose Option C because it respects a rule we hold to on every Okta engagement: custom code should only ever do identification and orchestration, never authentication. The moment a custom hook or external service starts deciding "this OTP is correct" or "this password is valid," you've built a second, unaudited identity provider sitting next to your real one. Keep Okta doing the one thing it's certified and battle-tested to do: verifying possession of a factor: and let custom code do the boring plumbing around it.
┌────────────┐ phone number ┌───────────────────────┐
│ User │ ───────────────────▶ │ Custom Recovery Page │
│ (mobile) │ │ (Okta hosted widget, │
└────────────┘ │ custom recovery view)│
└──────────┬─────────────┘
│ lookup request
▼
┌───────────────────────┐
│ Lookup Function │
│ (serverless, stateless)│
│ phone -> userId │
└──────────┬─────────────┘
│ resolved userId (or generic "check your phone")
▼
┌───────────────────────┐
│ Okta Identity Engine │
│ native recovery flow:│
│ - selects Phone │
│ authenticator │
│ - sends real OTP │
│ - verifies OTP │
│ - forces new password│
│ - re-enroll MFA if req'd│
└───────────────────────┘
What we needed before we could touch the dev tenant
With Option C chosen, we put together a resource list: partly for our own sprint planning, partly because Cloud Knowledge's procurement team needed to know exactly what licenses and access we required. Nothing here is exotic, which was itself a relief.
Okta platform resources
| Resource | Purpose | Already licensed? |
|---|---|---|
| Okta Identity Engine (OIE) dev tenant | Isolated environment for the POC, mirroring prod org settings | Provisioned new for this engagement |
| Okta Adaptive MFA / Phone authenticator | Native SMS + Voice OTP delivery and verification | Yes, already in the org's SKU |
| Okta Sign-In Widget (hosted, customizable) | Front-end surface for the recovery experience, branded per business unit | Yes, included with OIE |
| Inline Hooks (Registration & custom SPI extension points) | Server-side extension points to call our lookup function during the flow | Yes, included with OIE, no extra SKU |
| Okta System Log / Event Hooks | Audit trail and near-real-time alerting on recovery attempts | Yes |
| Okta Workflows | Optional orchestration (notifications, ticket creation on repeated failures) | Not initially licensed: flagged as a Should-have gap, see Challenges |
Supporting infrastructure
| Resource | Purpose |
|---|---|
| Small serverless function (AWS Lambda behind API Gateway) | Stateless phone-number-to-user lookup, called by the Inline Hook |
| Okta Management API access (scoped API token / OAuth service app) | Used by the lookup function to query Users API by phone attribute, never to reset passwords directly |
| Secrets manager (AWS Secrets Manager) | Storage for the Okta API token used by the lookup function |
| Test phone numbers (5 x carrier SIMs across 3 carriers) | Real-world SMS delivery testing, not just Okta sandbox simulation |
| ServiceNow sandbox instance | Testing the "escalate to helpdesk" fallback path end-to-end |
People
- 1x Identity engineer (lead): Okta configuration, policy design, hook development
- 1x Backend engineer: serverless lookup function, API integration
- 1x Security architect (Cloud Knowledge side): policy review, threat modeling sign-off
- 1x QA engineer: test plan execution across devices/carriers
- 0.25 FTE Service Delivery Manager: helpdesk process alignment, UAT coordination
We've learned the hard way on other clients that skipping the "real SIM cards on real carriers" line item is a false economy. Okta's sandbox and simulated OTP delivery will tell you your policy logic works. It will not tell you that a regional carrier silently throttles alphanumeric sender IDs, or that a voice fallback call gets routed to voicemail before the user picks up. We budgeted for this explicitly, and it paid off: more on that in the Challenges section.
Inside the dev tenant: how we actually built this, step by step
This is the part most write-ups skip or gloss over, and it's the part we get asked for most often, so we're going to be thorough. Everything below happened in a throwaway Okta Identity Engine developer org, deliberately configured to mirror Cloud Knowledge's production policy structure (same authenticator set, same brand count, same group model) without touching a single real user record.
Step 1: Stand up the dev tenant and mirror the org shape
Create the tenant and baseline groups
We provisioned a fresh Okta Developer Edition org (OIE by default on new orgs) and recreated the three business-unit brand structure Cloud Knowledge runs in production: brand_corporate, brand_fieldops, and brand_contractors, each with its own custom domain placeholder and its own Sign-In Widget theme. We also recreated the core groups: Everyone, FieldStaff, CorporateStaff, Contractors, and HelpdeskTier2 (used later for the escalation path).
Seed representative test users
We generated 40 synthetic users covering the real-world edge cases from the ticket analysis: users with phone + email, phone only, email only (legacy), duplicate-looking names, international phone formats (Cloud Knowledge has staff in four countries), and a few deliberately "dirty data" records: phone numbers stored with inconsistent formatting: because production data is never as clean as anyone wants to admit.
File Tag
okta-cli / seed-users.json (excerpt){
"profile": {
"firstName": "Priya",
"lastName": "Natarajan",
"email": "priya.natarajan@fieldops.cloudknowledge-dev.test",
"login": "priya.natarajan@fieldops.cloudknowledge-dev.test",
"primaryPhone": "+91 98765 43210",
"mobilePhone": "+91 98765 43210",
"department": "FieldOps",
"employeeStatus": "ACTIVE"
},
"credentials": {
"password": { "value": "TempPass!2026Dev" }
},
"groupIds": ["00g1fieldstaffXXXXXXXX"]
}
Step 2: Configure the Phone authenticator as a recovery-eligible factor
Under Security to Authenticators, we confirmed the Phone authenticator was enabled org-wide and, critically, checked its configuration for Recovery eligibility: by default a factor can be available for MFA but not enabled for account recovery, and these are separate toggles that are easy to miss.
Security -> Authenticators -> Phone -> Edit
Method: SMS, Voice
Allowed usages: ✅ Enroll ✅ Authenticate ✅ Recover
OTP length: 6 digits
OTP validity window: 5 minutes
Delivery retry limit: 3 attempts per 10-minute rolling window
Set authenticator priority in the Authenticator Enrollment Policy
We created a dedicated Authenticator Enrollment Policy scoped to the FieldStaff and Contractors groups where Phone is Required at first sign-in, ahead of Email, so that by the time this population needs recovery, a verified phone number already exists on the profile: this directly addresses the "Should have: proactive enrollment" requirement from the workshop.
Step 3: Rebuild the Recovery policy around phone-first identification
Password policy to recovery settings
Inside Security to Authentication Policies, we edited the org's Password policy to change the recovery configuration: instead of "Email only," we enabled both Email and Phone as eligible recovery authenticators, and, per the agreed design, marked Email as optional at the identification step rather than a hard requirement.
{
"type": "PASSWORD",
"name": "CloudKnowledge - FieldOps Recovery Policy",
"settings": {
"recovery": {
"factors": {
"recovery_question": { "status": "INACTIVE" },
"okta_email": { "status": "ACTIVE", "properties": { "recoveryToken": { "tokenLifetimeMinutes": 60 } } },
"okta_sms": { "status": "ACTIVE" },
"okta_call": { "status": "ACTIVE" }
}
},
"password": {
"complexity": { "minLength": 12, "minLowerCase": 1, "minUpperCase": 1,
"minNumber": 1, "minSymbol": 1, "excludeUsername": true }
}
}
}
We turned Recovery Question off entirely. Security questions are a legacy factor with well-documented weaknesses (answers are often guessable, discoverable via social media, or reused across sites), and leaving them active would have quietly undermined the security posture we were promising. This wasn't in the original scope, but our security architect partner flagged it during review and we agreed it belonged in the same change.
Building the phone-first identification step
This is where the "custom flow" the client asked for actually lives. Okta's native recovery flow assumes you start by typing a username (usually email). We needed a step in front of that where a user can type only a phone number, and have the system quietly resolve which Okta account that maps to: without ever revealing to an attacker whether a given phone number exists in the directory.
Step 4: Customize the Sign-In Widget's recovery entry point
We used the Sign-In Widget's configuration hooks (the i18n overrides and custom features flags) plus a small amount of custom JavaScript on the hosted sign-in page to relabel the recovery identifier field and add client-side format validation for phone numbers, while leaving Okta's own submission handling untouched.
const config = {
baseUrl: 'https://fieldops.cloudknowledge-dev.test',
clientId: OKTA_CLIENT_ID,
redirectUri: window.location.origin + '/login/callback',
authParams: { issuer: 'https://fieldops.cloudknowledge-dev.test/oauth2/default' },
features: {
selfServiceUnlock: true,
router: true
},
i18n: {
en: {
'primaryauth.username.placeholder': 'Phone number or email',
'account.unlock.email.or.username.placeholder': 'Phone number or email',
'password.forgot.email.or.username.placeholder': 'Phone number or username',
'password.forgot.email.or.username.tooltip':
'Use the mobile number on file with HR. Don\u2019t have it handy? You can use your email instead.'
}
},
helpLinks: {
help: 'https://help.cloudknowledge-dev.test/account-recovery'
}
};
Under the hood, when a user submits that field during the "Forgot Password" flow, Okta's Identity Engine treats the submitted value as the username parameter of the recovery request: exactly as it always has. The trick is what happens next: rather than relying purely on an exact match against the login attribute (which is email in this org), we needed a resolution step that also checks the primaryPhone / mobilePhone attributes and translates a phone-number submission into the correct login before Okta's native recovery logic takes over.
Step 5: The Inline Hook that does the translation
We implemented this using a Password Import Inline Hook pattern repurposed as a pre-recovery resolution hook, calling out to our serverless lookup function. Okta calls the external service synchronously during the recovery attempt and expects a fast, well-formed JSON response; if the external call fails or times out, Okta falls back to its own native matching behavior rather than blocking the user.
const { Client } = require('@okta/okta-sdk-nodejs');
const okta = new Client({
orgUrl: process.env.OKTA_ORG_URL,
token: process.env.OKTA_API_TOKEN // scoped, read-only Users API token
});
// Normalizes phone input to E.164 before comparison.
function normalizePhone(raw) {
const digits = raw.replace(/[^\d+]/g, '');
return digits.startsWith('+') ? digits : `+${digits}`;
}
exports.handler = async (event) => {
const submitted = event.data.context.credential.username || '';
const looksLikePhone = /^[\d\s()+-]{7,}$/.test(submitted);
// Always return a generic 200 shape. Never leak whether a match
// was found. That alone would enable phone-number enumeration.
const genericResponse = {
commands: [] // no-op: let Okta's native flow continue unmodified
};
if (!looksLikePhone) {
return { statusCode: 200, body: JSON.stringify(genericResponse) };
}
const normalized = normalizePhone(submitted);
try {
const results = await okta.userApi.listUsers({
search: `profile.primaryPhone eq "${normalized}" or profile.mobilePhone eq "${normalized}"`
});
const matches = [];
for await (const user of results) matches.push(user);
if (matches.length === 1) {
// Rewrite the credential the native flow will use, so Okta's
// own recovery machinery takes over from here. We never
// touch OTP generation, verification, or password reset.
return {
statusCode: 200,
body: JSON.stringify({
commands: [{
type: 'com.okta.identity.patch',
value: [{ op: 'replace', path: '/credential/username', value: matches[0].profile.login }]
}]
})
};
}
// Zero or multiple matches: do NOT reveal which. Fall through
// to Okta's native (generic, non-enumerable) "if this account
// exists" messaging.
return { statusCode: 200, body: JSON.stringify(genericResponse) };
} catch (err) {
console.error('lookup-function error', err); // never logs the phone number itself
return { statusCode: 200, body: JSON.stringify(genericResponse) };
}
};
Notice that the function returns the exact same generic shape whether the phone number matched zero, one, or more than one account. This was the single most heavily reviewed piece of code in the whole POC. An enumeration bug here: where the response timing or shape differs based on whether a match exists: would let an attacker fish for valid employee phone numbers. We added synthetic delay to equalize response time across all three branches for the same reason.
Step 6: Registering the hook in Okta
Admin Console -> Workflow -> Inline HooksHook type: Password Import Inline Hook (repurposed for pre-recovery resolution)
Hook name: fieldops-phone-recovery-resolver
Invoked on: Recovery flow, prior to authenticator selection
Endpoint URL: https://xxxxxx.execute-api.ap-south-1.amazonaws.com/prod/resolve
Auth header: Authorization: Bearer <shared secret, rotated 90 days>
Timeout: 3000 ms (Okta falls back to native behavior on timeout)
Once the hook rewrites credential/username to the resolved login, control returns entirely to Okta's own Identity Engine recovery flow: authenticator selection, OTP dispatch, OTP verification, and the forced password reset all happen inside Okta, using Okta's own rate limiting, lockout thresholds, and System Log entries. This was exactly the boundary we set out to hold in the architecture section: our code identifies, Okta authenticates.
