← Back to guides

FIDO2 / YubiKey Inventory

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 FIDO2 / WebAuthn Audit

What you're building

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:

Why this drives off a table instead of List Users. Okta's user-listing cards page at 200 and push you into streaming, which passes whole records and gets awkward fast. Since you already have a CSV export, importing it into a Workflows table turns the whole problem into a flat Search Rows → For Each — one pass, no cursors, no streaming helper. The only ceiling is Search Rows returns a maximum of 3,500 rows; past that, import in batches.
Set expectations on serials up front. WebAuthn deliberately does not expose a device serial number to the relying party, so Okta never receives one. What you get instead is 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.

Build order & conventions

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.

Trigger card Function / flow control Connector (Okta / Tables) Logic / branching

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.

Field-kind key (inside card tables)

PasteCopy the literal value (click the Copy button) and paste into the field.
PickClick the dropdown and choose the listed option.
DragDrag the named output pill from an earlier card onto this input field.
ToggleFlip the switch.
AddClick + to add a new row / sub-field inside this field group.
0 / 0 complete

Prereq — Export the user list & check the Okta connection

P One-time prep in the Okta Admin Console

A. Export the candidate users

  1. Admin Console → ReportsReportsMultifactor Authentication section → MFA enrollment by user.
  2. Click CSV Export. The download carries more columns than the on-screen table — importantly it includes user.id.
  3. Open the CSV. Keep only rows whose Authenticator type column mentions the FIDO2 / Passkeys authenticator. Everyone else has no WebAuthn enrollment and would just burn API calls.
  4. Cut it down to exactly three columns, in this order, and save as a new CSV:
    Column in your CSVComes from
    User IDuser.id
    LoginUser username
    Full NameUser full name
Why filter by Authenticator type first. That column is a comma-separated list of the distinct authenticator types a user has enrolled — it will tell you someone has a FIDO2 credential, but not whether it's a YubiKey, a Touch ID passkey, or Windows Hello. That's precisely the gap this flow closes. Use the column to narrow the population, then let the flow tell you what the devices actually are.

B. Confirm the Okta connection can read factors

  1. Workflows console → Connections. If you already have an Okta connection for your org, you're most of the way there.
  2. The first time you drop an Okta card into a flow, Workflows asks you to authorize an account from your org and grant the scopes that card needs. The factors call needs read access to users (okta.users.read).
  3. The authorizing admin needs a role that can read user factor enrollments. Super Admin always works; a custom read-only admin role usually does too.
Don't reuse a personal admin account. Whatever account authorizes this connection is what shows in the System Log for every one of these API calls. Use a dedicated service admin so the audit trail stays clean — you're building this for an audit.

Step 1 — Create the two tables

1 Your folder → Tables tab → + New Table (twice)

Table one holds the input list. Name: FIDO2 Input

Column nameTypeHolds
User IDTextOkta user ID from the export
LoginTextUsername, carried through for readability
Full NameTextDisplay name, carried through for readability

Table two holds the output. Name: FIDO2 Enrollments

Column nameTypeSource (factor JSON field)
User IDTextpassed through from the input table
LoginTextpassed through from the input table
Full NameTextpassed through from the input table
Factor TypeTextfactorType
ProviderTextprovider
AuthenticatorTextprofile.authenticatorName
Credential IDTextprofile.credentialId
StatusTextstatus
EnrolledTextcreated
Last UsedTextlastVerified
Enrolled and Last Used are Text, not Date — on purpose. Okta returns these as ISO 8601 strings and 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.
Every factor gets a row, not just YubiKeys. No filtering happens inside the flow. You'll get Okta Verify, phone, and WebAuthn rows side by side, and you filter in Excel at the end (Step 6). This removes every branching card from the build, which is where these flows usually break — and the full picture is more useful in an audit than a pre-filtered one anyway.

Step 2 — Import your CSV into the input table

2 FIDO2 Input table → Import
  1. Open the FIDO2 Input table. Confirm the three columns from Step 1 exist — the import maps into existing columns, it does not create them.
  2. Click Import to bring up the Import Data dialog.
  3. Drag your CSV into the dialog, or click Choose CSV File.
  4. Map each CSV field to a table column. If your CSV headers match the column names exactly, Workflows preselects the right mapping for you — which is the whole reason Step P had you rename the headers to User ID / Login / Full Name.
  5. Click Import.
  6. Spot-check: the row count should match your CSV, and User ID values should look like 00u1a2b3c4d5E6f7g8h9. If that column came through blank you mapped the wrong CSV field — clear the table and reimport.
Keep it under 3,500 rows. Search Rows in Step 5 returns at most 3,500 rows no matter what you set the limit to. If your filtered export is bigger, split it into batches, run the whole thing per batch, and clear the input table between runs.

Step 3 — Flow 3: FIDO2: Write Enrollment Row

3 Helper flow that writes one row per enrolled authenticator

Your folder → + New Flow → name: FIDO2: Write Enrollment Row . This flow has two cards.

C1 Helper Flow Trigger (built-in)
The flow's entry point. Four inputs: one factor object from Flow 2's For Each, plus the three user fields carried down from the input table.
Placement: click the first card in the empty flow and choose Helper Flow, then configure its inputs.
Input fieldKindValue
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):
Sub-fieldType
factorTypeText
providerText
statusText
createdText
lastVerifiedText
profileObject
Then open profile and add two sub-fields inside it:
Sub-fieldType
authenticatorNameText
credentialIdText
Input 2 name Paste userId — type Text
Input 3 name Paste login — type Text
Input 4 name Paste fullName — type Text
C2 Create Row Tables
Writes one row into FIDO2 Enrollments for the factor this invocation received.
Placement: click + after C1 → Add AnotherTablesCreate Row. In the card, click Choose Table, pick FIDO2 Enrollments, select all fields, click Done. The input rows below then appear automatically, one per table column.
Input fieldKindValue
User IDDragthe userId pill from C1
LoginDragthe login pill from C1
Full NameDragthe fullName pill from C1
Factor TypeDragfactor → factorType from C1
ProviderDragfactor → provider from C1
AuthenticatorDragfactor → profile → authenticatorName from C1
Credential IDDragfactor → profile → credentialId from C1
StatusDragfactor → status from C1
EnrolledDragfactor → created from C1
Last UsedDragfactor → lastVerified from C1

Save the flow and turn it ON. A helper flow that's off silently does nothing when called.

Step 4 — Flow 2: FIDO2: Read User Factors

4 Helper flow that calls the Okta API for one user

Your folder → + New Flow → name: FIDO2: Read User Factors . This flow has six cards.

C1 Helper Flow Trigger (built-in)
Entry point. Receives one user's three fields from Flow 1's For Each.
Placement: click the first card in the empty flow and choose Helper Flow.
Input fieldKindValue
Input 1 namePaste userId — type Text
Input 2 namePaste login — type Text
Input 3 namePaste fullName — type Text
C2 Concatenate Function — Text
Builds the relative API path for this user. Custom API Action's URL field takes a single text value, so the path has to be assembled first.
Placement: click + after C1 → FunctionTextConcatenate. Use + inside the card to get three input slots.
Input fieldKindValue
Input 1Paste /api/v1/users/
Leading slash is required. Trailing slash is required.
Input 2Drag the userId pill from C1
Input 3Paste /factors

Output: a single text pill. For a user ID of 00u1a2b3c4d5E6f7g8h9 it reads /api/v1/users/00u1a2b3c4d5E6f7g8h9/factors.

C3 Custom API Action Okta
Makes the authenticated call to the Okta factors endpoint. This is the card that replaces the "list factors" card Okta doesn't ship.
Placement: click + after C2 → OktaCustom API Action. If Workflows prompts you to authorize an Okta account and grant scopes, do it now.
FieldKindValue
Request Type (Options) Pick GET
Not 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.

Why the next two cards exist — read this before you build them.

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.

C4 Stringify Function — JSON
Converts the API response back into a JSON string so Parse can re-type it.
Placement: click + after C3 → FunctionJSONStringify.
Input fieldKindValue
Value to stringifyDrag the Body output pill from C3

Output: one Text pill containing the raw JSON array.

C5 Parse Function — JSON
Reads that string back in as a properly typed List of factor objects — the thing For Each can actually iterate.
Placement: click + after C4 → FunctionJSONParse.
FieldKindValue
Input stringDrag the output pill from C4
Output typePick 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 namePaste factors
C6 For Each Function — List
Runs Flow 3 once per factor, passing the factor object plus the three user fields.
Placement: click + after C5 → FunctionListFor Each.
FieldKindValue
For each item in this listDrag the factors pill from C5
Run this FlowPick FIDO2: Write Enrollment Row
Selecting it makes that flow's four inputs appear as fields below.
factorPick click the field, choose item from the dropdown
userIdDrag the userId pill from C1
loginDrag the login pill from C1
fullNameDrag the fullName pill from C1
ConcurrencyPaste 1
Each 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.

Test this flow on its own before wiring Flow 1. Click Run in the toolbar (⌘+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.

Step 5 — Flow 1: FIDO2 Inventory: Run

5 Parent flow you trigger by hand

Your folder → + New Flow → name: FIDO2 Inventory: Run . Two cards, and no event card at all.

Leave the first card empty. A flow you run by hand doesn't need an event card — the Run button executes it regardless. Skip straight to adding the Search Rows card. (If you'd rather have this refresh on its own, put a Schedule trigger in that slot instead; everything downstream is identical.)
C1 Search Rows Tables
Pulls the whole imported user list out of the input table in one shot. This card is what replaces pagination.
Placement: first card in the flow → Add AnotherTablesSearch Rows. Click Choose Table and pick FIDO2 Input. Set the result-set option to All matching rows.
FieldKindValue
TablePick 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.
LimitPaste 3500
3,500 is the hard ceiling for this card — a higher number won't return more.
OffsetPaste 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.

C2 For Each Function — List
Hands each user row to Flow 2. This is the "for each on a list of users" that keeps streaming out of the picture.
Placement: click + after C1 → FunctionListFor Each.
FieldKindValue
For each item in this listDrag the rows list output from C1
Run this FlowPick FIDO2: Read User Factors
userIdPick click the field → itemUser ID
loginPick click the field → itemLogin
fullNamePick click the field → itemFull Name
ConcurrencyPaste 5
Five 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.

Step 6 — Run it and read the output

6 Execute, export, filter
  1. Open FIDO2 Inventory: Run and click Run in the toolbar. There are no inputs to supply.
  2. Watch the flow's run history. Flow 1 completes as soon as it's dispatched the work — Flows 2 and 3 keep running behind it, so give it time before judging the result.
  3. Open the FIDO2 Enrollments table. Rows should be accumulating.
  4. Export the table to CSV and open it in Excel.
  5. Filter Factor Type = webauthn. That's every FIDO2 credential in your org's sampled population.
  6. Now filter Authenticator contains YubiKey. That's your list.

What the values mean

ColumnWhat you'll see
Factor Typewebauthn is FIDO2. You'll also see signed_nonce (Okta FastPass), push, token:software:totp, sms, email, password and others depending on your policy.
ProviderFIDO on WebAuthn rows.
AuthenticatorThe make and model Okta resolved from the AAGUID: YubiKey 5C, YubiKey 5 NFC, Touch ID, Windows Hello, iCloud Keychain, and so on.
Credential IDLong 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 UsedBlank 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.
Blank Authenticator on a webauthn row is meaningful, not a bug. Okta only learns the make and model if the user accepted the attestation prompt at enrollment. Someone who declined it produces a WebAuthn row with no device name. Treat those as needs manual reconciliation against your procurement records — not as "not a YubiKey." If there are many, consider turning on attestation validation for the authenticator so future enrollments are always identifiable.

Three findings worth pulling out while you're in the file

  • Single-key users. Pivot on Login and count YubiKey rows. Anyone at exactly 1 has no backup — they're a lockout ticket waiting to happen.
  • Never-used keys. Last Used blank on a key enrolled months ago means you paid for hardware nobody adopted.
  • Keys on departed users. Join 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.
Rerunning. The flow appends; it never clears. Before a second run, either empty the 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.

Troubleshooting

7 When it doesn't work
SymptomCause & 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.

Reference

Okta documentation this build was verified against: