Build an Okta Workflow that walks a list of users you already exported, pulls every enrolled authenticator for each one, and writes them to a table you can export to CSV. Produces user → device model → credential ID → enrollment date — the closest thing Okta will give you to a security-key asset register.
Okta Workflows has no native card for reading a user's factors — the Okta connector's 60-odd action cards cover users, groups, apps and imports, but not MFA enrollments. You get there with Custom API Action, which makes an authenticated call to any Okta API endpoint using the connection you've already authorized.
Three flows, two tables, no pagination:
/api/v1/users/{id}/factors, converts the response into a real list, iterates it.authenticatorName — the make and model Okta resolved from the AAGUID at enrollment, e.g. YubiKey 5C or YubiKey 5 NFC. That is enough to tell a hardware key apart from a laptop biometric, and enough to prove who holds a key, but it will not tie a row to an engraved serial. If serial-level tracking is a hard requirement, that's a purchasing decision (Yubico FIDO pre-registration), not a reporting one.
We build bottom-up: Flow 3 first, then Flow 2, then Flow 1. That way every For Each card has a real target flow to pick when you configure it — no half-wired state.
Inside each step, every card gets its own block with four things: which card to pick, what it does, where it goes in the flow, and exactly what to paste / drag into each field.
| Paste | Copy the literal value (click the Copy button) and paste into the field. | |
| Pick | Click the dropdown and choose the listed option. | |
| Drag | Drag the named output pill from an earlier card onto this input field. | |
| Toggle | Flip the switch. | |
| Add | Click + to add a new row / sub-field inside this field group. | |
user.id.| Column in your CSV | Comes from |
|---|---|
User ID | user.id |
Login | User username |
Full Name | User full name |
okta.users.read).Table one holds the input list. Name: FIDO2 Input
| Column name | Type | Holds |
|---|---|---|
User ID | Text | Okta user ID from the export |
Login | Text | Username, carried through for readability |
Full Name | Text | Display name, carried through for readability |
Table two holds the output. Name: FIDO2 Enrollments
| Column name | Type | Source (factor JSON field) |
|---|---|---|
User ID | Text | passed through from the input table |
Login | Text | passed through from the input table |
Full Name | Text | passed through from the input table |
Factor Type | Text | factorType |
Provider | Text | provider |
Authenticator | Text | profile.authenticatorName |
Credential ID | Text | profile.credentialId |
Status | Text | status |
Enrolled | Text | created |
Last Used | Text | lastVerified |
lastVerified is frequently absent on a factor that's enrolled but never used. A Date-typed column turns both of those into runtime errors that kill the row write. Text always lands; convert in Excel afterward if you need real dates.
FIDO2 Input table. Confirm the three columns from Step 1 exist — the import maps into existing columns, it does not create them.User ID / Login / Full Name.User ID values should look like 00u1a2b3c4d5E6f7g8h9. If that column came through blank you mapped the wrong CSV field — clear the table and reimport.Your folder → + New Flow → name: FIDO2: Write Enrollment Row . This flow has two cards.
| Input field | Kind | Value | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Input 1 name | Paste | factor
— type Object | ||||||||||||||||||||
| factor sub-fields | Add | An Object input requires you to declare its sub-fields on the trigger card — Workflows does not infer them from runtime data. Add these six, exactly as named (the API is case-sensitive):
profile and add two sub-fields inside it:
| ||||||||||||||||||||
| Input 2 name | Paste | userId
— type Text | ||||||||||||||||||||
| Input 3 name | Paste | login
— type Text | ||||||||||||||||||||
| Input 4 name | Paste | fullName
— type Text |
FIDO2 Enrollments, select all fields, click Done. The input rows below then appear automatically, one per table column.| Input field | Kind | Value |
|---|---|---|
| User ID | Drag | the userId pill from C1 |
| Login | Drag | the login pill from C1 |
| Full Name | Drag | the fullName pill from C1 |
| Factor Type | Drag | factor → factorType from C1 |
| Provider | Drag | factor → provider from C1 |
| Authenticator | Drag | factor → profile → authenticatorName from C1 |
| Credential ID | Drag | factor → profile → credentialId from C1 |
| Status | Drag | factor → status from C1 |
| Enrolled | Drag | factor → created from C1 |
| Last Used | Drag | factor → lastVerified from C1 |
Save the flow and turn it ON. A helper flow that's off silently does nothing when called.
Your folder → + New Flow → name: FIDO2: Read User Factors . This flow has six cards.
| Input field | Kind | Value |
|---|---|---|
| Input 1 name | Paste | userId — type Text |
| Input 2 name | Paste | login — type Text |
| Input 3 name | Paste | fullName — type Text |
| Input field | Kind | Value |
|---|---|---|
| Input 1 | Paste | /api/v1/users/ Leading slash is required. Trailing slash is required. |
| Input 2 | Drag | the userId pill from C1 |
| Input 3 | Paste | /factors |
Output: a single text pill. For a user ID of 00u1a2b3c4d5E6f7g8h9 it reads /api/v1/users/00u1a2b3c4d5E6f7g8h9/factors.
| Field | Kind | Value |
|---|---|---|
| Request Type (Options) | Pick | GETNot GET(Streaming). Streaming exists for endpoints returning huge collections and needs its own dedicated flow — a user has a handful of factors, so plain GET is correct and simpler. |
| Relative URL | Drag | the output pill from C2 Relative, not absolute — the Okta connector already knows your org URL. Value must start with /, which C2 guarantees. |
| Headers | — | Leave empty. The connector sets auth and content-type itself. |
| Query | — | Leave empty. This endpoint takes no query parameters. |
Outputs: Status Code (Number), Headers (Object), Body (Object). There is no Body input on a GET.
Custom API Action types its Body output as an Object. But /api/v1/users/{id}/factors returns a top-level JSON array — there's no key to reach into with Object → Get, and For Each demands a List. So you round-trip it: Stringify turns the body back into raw JSON text, then Parse re-reads that text with the output type set explicitly to List. Okta's own Parse documentation is direct about this — you must set the output type to match what the string actually contains, or you get a runtime error. Two cards, and the array problem is gone.
| Input field | Kind | Value |
|---|---|---|
| Value to stringify | Drag | the Body output pill from C3 |
Output: one Text pill containing the raw JSON array.
| Field | Kind | Value |
|---|---|---|
| Input string | Drag | the output pill from C4 |
| Output type | Pick | List This is the single most important setting in the whole build. Leave it as Object and the flow fails at runtime with a type error. |
| Output name | Paste | factors |
| Field | Kind | Value |
|---|---|---|
| For each item in this list | Drag | the factors pill from C5 |
| Run this Flow | Pick | FIDO2: Write Enrollment RowSelecting it makes that flow's four inputs appear as fields below. |
| factor | Pick | click the field, choose item from the dropdown |
| userId | Drag | the userId pill from C1 |
| login | Drag | the login pill from C1 |
| fullName | Drag | the fullName pill from C1 |
| Concurrency | Paste | 1Each user only has a few factors; parallelism buys nothing here and just makes the run log harder to read. |
For Each returns no outputs. It's the last card in this flow.
Save and turn the flow ON.
⌘+Shift+Enter on macOS), paste one real user ID from your table into userId, put anything in login and fullName, and run it. Then check the FIDO2 Enrollments table — you should see one row per factor that user has. Debugging one user here is enormously cheaper than debugging 400 through Flow 1.
If C3 returns Status Code: 403, the connection's admin role can't read factors. 404 means the user ID is wrong or you dropped the trailing slash in C2's Input 1.
Your folder → + New Flow → name: FIDO2 Inventory: Run . Two cards, and no event card at all.
FIDO2 Input. Set the result-set option to All matching rows.| Field | Kind | Value |
|---|---|---|
| Table | Pick | FIDO2 Input (the docs call this field Table ID; the UI shows a Choose Table picker) |
| Filter (Where Expression) | — | Leave empty — you want every row. You already did the filtering in the CSV. |
| Sort (Column) | — | Leave empty. |
| Sort (Direction) | — | Leave empty. |
| Limit | Paste | 3500 3,500 is the hard ceiling for this card — a higher number won't return more. |
| Offset | Paste | 0 (the default — start at the first record) |
Output with "All matching rows" selected: a list of rows, each carrying its Row id, Created, Updated, and Fields.
| Field | Kind | Value |
|---|---|---|
| For each item in this list | Drag | the rows list output from C1 |
| Run this Flow | Pick | FIDO2: Read User Factors |
| userId | Pick | click the field → item → User ID |
| login | Pick | click the field → item → Login |
| fullName | Pick | click the field → item → Full Name |
| Concurrency | Paste | 5Five users in flight at a time. This is one API call per user, so a few hundred users finishes quickly without crowding your org's rate limit. Drop to 1 if you start seeing 429 status codes in C3 of Flow 2. |
Save and turn the flow ON.
FIDO2 Inventory: Run and click Run in the toolbar. There are no inputs to supply.FIDO2 Enrollments table. Rows should be accumulating.Factor Type = webauthn. That's every FIDO2 credential in your org's sampled population.Authenticator contains YubiKey. That's your list.| Column | What you'll see |
|---|---|
Factor Type | webauthn is FIDO2. You'll also see signed_nonce (Okta FastPass), push, token:software:totp, sms, email, password and others depending on your policy. |
Provider | FIDO on WebAuthn rows. |
Authenticator | The make and model Okta resolved from the AAGUID: YubiKey 5C, YubiKey 5 NFC, Touch ID, Windows Hello, iCloud Keychain, and so on. |
Credential ID | Long opaque base64 string. Not a serial, but it's the stable per-key identifier — it's what you match on if you ever need to revoke one specific credential. |
Last Used | Blank means enrolled but never used to authenticate. On a hardware key that's usually a spare in a drawer, or a key that was handed out and never set up properly. |
Login and count YubiKey rows. Anyone at exactly 1 has no backup — they're a lockout ticket waiting to happen.Last Used blank on a key enrolled months ago means you paid for hardware nobody adopted.Login against your deactivated-user list. Those are physical assets still in someone's possession, and they're the finding an auditor will actually care about.FIDO2 Enrollments table by hand or add a Tables → Clear Table card as the very first card in Flow 1. Otherwise you'll be looking at duplicated rows and won't immediately notice.
| Symptom | Cause & fix |
|---|---|
| Flow 1 finishes instantly, table stays empty | Flow 2 or Flow 3 is switched OFF. A helper flow that's off is called and does nothing, silently. Turn both on. |
| Runtime type error on the Parse card | Output type on C5 in Flow 2 is still Object. It must be List. |
Status Code: 403 from Custom API Action |
The admin account behind the Okta connection can't read user factors. Reauthorize the connection with an account holding a role that can, and make sure the user-read scope was granted. |
Status Code: 404 from Custom API Action |
Malformed path. Check C2 in Flow 2 — Input 1 must be /api/v1/users/ with the trailing slash, Input 3 must be /factors with a leading one. Or the User ID column imported blank. |
Status Code: 429 |
Rate limited. Drop Concurrency on Flow 1's For Each to 1 and rerun. |
| Rows land but Authenticator and Credential ID are always empty | The profile sub-fields weren't declared on Flow 3's trigger card. Workflows won't infer nested Object fields — go back to Step 3 C1 and add authenticatorName and credentialId inside profile. |
| Fewer rows than expected | Search Rows capped at 3,500, or the result-set option is on "First matching row" instead of All matching rows. |
| Everything duplicated | You ran it twice without clearing FIDO2 Enrollments. See the rerun note in Step 6. |
Okta documentation this build was verified against:
item mapping)profile.authenticatorName, profile.credentialId)