Rui Qiu's Tech Blog
← Back to all posts

August 3, 2026

Automate Hardware OATH Token Provisioning in Entra ID[Updated]

Automating Hardware OATH (FOB) Token Provisioning in Microsoft Entra ID

with Microsoft Forms, Power Automate, SharePoint, Azure Automation & Microsoft Graph

A field-tested build guide — including every bug that bit us and how each one was fixed.

Some users simply cannot use phone-based MFA: offshore contractors on locked-down terminals, call-centre staff, secure areas where phones and USB devices are prohibited. For those populations, hardware OATH-TOTP tokens (“FOBs”) are often the only workable second factor.

Provisioning them by hand does not scale. Each token arrives as an encrypted seed file from the vendor, and each user must be matched to a serial number and registered individually. This guide shows how to automate the whole thing end to end.

Note: every name, domain, ID and link below is a generic placeholder. Replace them with your own environment’s values.


Table of Contents

  1. What This Solution Does
  2. Architecture
  3. Gotcha #1: The Seed Secret Is Encrypted in the XML
  4. Gotcha #2: Graph Permissions Alone Are Not Enough
  5. Gotcha #3: Forms No Longer Syncs Excel in the Background
  6. Prerequisites
  7. SharePoint Setup
  8. The Power Automate Flow
  9. Designing for Real-World Messiness
  10. PowerShell Traps Worth Knowing
  11. Retry Policy: One Attempt, Then Ask the User
  12. Testing & Go-Live
  13. Troubleshooting Reference
  14. Security & Compliance Notes
  15. Lessons Learned

1. What This Solution Does

A user submits their token’s serial number through a Microsoft Form. Power Automate writes that submission to a SharePoint list. A scheduled Azure Automation runbook then:

New vendor seed batches are ingested automatically from .eml files dropped into a SharePoint folder.

Design principles


2. Architecture

Hardware OATH (FOB) provisioning architecture

Figure 1 — end-to-end provisioning flow

Components

ComponentRole
Microsoft FormsIntake: serial number, target account, replacement flag
Power AutomateWrites one SharePoint list item per submission
Requests listThe intake queue the runbook reads
Seeds/Inbox folderLanding area for vendor seed .eml pairs
SeedVault listPermanent serial → Base32 secret store
ProcessedResponses listDedup and audit record
Azure Automation runbookScheduled PowerShell 7.2 job using a managed identity
Microsoft Graph (beta)Hardware OATH endpoints: create device, bind to user
Entra IDWhere the activated token lives on the user

Per-run flow

  1. Connect to Graph with the managed identity
  2. Load dedup state; skip anything already Registered or Failed
  3. Read pending items from the requests list
  4. Load the SeedVault; parse any new .eml seeds and append new serials
  5. For each request: resolve the account, validate the serial, match a seed
  6. Create the device, wait for replication, bind it to the user
  7. Retire the old token if this is a replacement
  8. Email the user; record the outcome
  9. Send the admin digest

3. Gotcha #1: The Seed Secret Is Encrypted in the XML

Vendor seed archives are password-protected ZIPs — sometimes disguised with a .pdf extension. Inside you typically find three files:

key.bin
seeds.txt
seeds.xml

The obvious move is to parse seeds.xml, since PSKC is the standard format. Do not. Its secret is AES-encrypted:

<Secret>
  <EncryptedValue>
    <EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
    <CipherData><CipherValue>AAAABBBBCCCCDDDDEEEEFFFF...</CipherValue></CipherData>
  </EncryptedValue>
</Secret>

Without the vendor’s decryption key, that value is useless.

seeds.txt contains the same secrets in plaintext Base32, ready to hand straight to Entra:

Serial Number,Base32 Secret
1234567890123,ABCDEFGHIJKLMNOPQRSTUVWXYZ234567
1234567890124,ZYXWVUTSRQPONMLKJIHGFEDCBA765432

How this bug presented

Our first parser walked the XML looking for PlainValue. It found one — but the only unencrypted PlainValue elements are Counter, Time and TimeInterval. So every secret came back as "0", a single character.

Graph rejected every registration with BadRequest, and the cause was completely non-obvious from the error.

Sanity check: a valid Base32 secret for SHA-1 decodes to 20 bytes (32 Base32 characters). If yours doesn’t, you’re reading the wrong field.


4. Gotcha #2: Graph Permissions Alone Are Not Enough

This one cost hours. The documented app permissions were all granted:

PermissionPurpose
Policy.ReadWrite.AuthenticationMethodCreate devices in the tenant pool
UserAuthenticationMethod.ReadWrite.AllBind a token to a user
User.Read.AllResolve users
Sites.ReadWrite.AllRead/write SharePoint lists
Mail.SendNotifications

Creating the device worked. Binding it to a user returned 403 Forbidden, every time.

The missing piece is a directory role. Writing to another user’s authentication methods is a privileged operation guarded by RBAC on top of the Graph scope:

New-MgRoleManagementDirectoryRoleAssignment -BodyParameter @{
  principalId      = "<managed-identity-object-id>"
  roleDefinitionId = "c4e39bd9-1100-46d3-8c65-fb160da0071f"  # Authentication Administrator
  directoryScopeId = "/"
}

Allow roughly 15 minutes for propagation.

Which role? Authentication Administrator covers non-admin users and is the least-privilege choice. Privileged Authentication Administrator is required to touch admin accounts — but you probably shouldn’t be putting non-phishing-resistant tokens on admin accounts anyway.


5. Gotcha #3: Forms No Longer Syncs Excel in the Background

The original design read responses straight from the live Excel workbook that Forms maintains. It worked — until new submissions stopped appearing.

Microsoft changed the Forms→Excel sync mechanism in November 2024. The workbook now updates only when a human opens it in Excel for the web or desktop. Microsoft’s own guidance is explicit that automation reading that file will not see new responses.

The fix: stop reading the workbook. Put Power Automate between Forms and your automation:

Forms → Power Automate → SharePoint list → runbook

The flow fires on submission and writes a list item immediately. The runbook reads the list. No human interaction required.

This is worth knowing even if you’re building something unrelated — any workflow that reads a Forms-linked workbook via API is affected.


6. Prerequisites

Managed identity — Graph application permissions

The five listed in section 4.

Managed identity — directory role

Authentication Administrator (see section 4).

Scope mail sending

Restrict the identity so it can only send as your service mailbox:

New-ApplicationAccessPolicy -AppId "<mi-app-id>" `
  -PolicyScopeGroupId "<sender-scope-group>@example.com" `
  -AccessRight RestrictAccess -Description "Limit automation sender"

Custom PowerShell modules (runtime 7.2+)

Version your modules. More on why in section 10.


7. SharePoint Setup

Folders

Documents/Seeds/Inbox        ← vendor .eml pairs land here
Documents/Seeds/Processed    ← archived after ingestion
Documents/Seeds/Manual       ← optional, for manual imports

Restrict these to your identity team and the managed identity. Seed material is sensitive.

List: SeedVault

ColumnTypeNotes
TitleSingle lineSerial number — enforce unique values
SecretSingle lineBase32 secret
IntervalNumber30
HashFunctionSingle linehmacsha1
ManufacturerSingle lineVendor name
ModelSingle lineToken model
SOSingle lineSource order, for auditing
IngestedDateDate & TimeWhen added

List: Requests (intake queue)

ColumnTypeNotes
TitleSingle lineForms response Id — enforce unique values
RequesterUPNSingle lineThe account the token registers to
ResponderEmailSingle lineThe mailbox to notify
SerialNumberSingle lineThe submitted serial
IsReplacementYes/NoRetires the previous token when true

List: ProcessedResponses (dedup + audit)

ColumnType
TitleSingle line — response Id, enforce unique
UPN / Serial / Status / RunIdSingle line
ProcessedDateDate & Time

8. The Power Automate Flow

  1. Trigger: When a new response is submitted (Microsoft Forms)
  2. Action: Get response details
  3. Action: Create item in the requests list

Two things that will trip you up

The form must be group-owned. If a form is merely shared with you, the Forms trigger will never fire — silently. Move it into a Microsoft 365 group and make sure you are a member, not just an owner. (Being an owner does not imply membership.)

Yes/No columns hide dynamic content. A SharePoint Yes/No field renders as a fixed dropdown, so the dynamic-content panel appears empty. Click Enter custom value to switch it to a free-text box, then insert the answer or an expression:

if(equals(outputs('Get_response_details')?['body/<question-id>'],'Yes'), true, false)

Insert the answer from the picker first, then wrap it — that way you never have to guess the question ID.


9. Designing for Real-World Messiness

Users type things wrong. Directories are inconsistent. Build for it.

Domain typos

Auto-correct with a Levenshtein distance of ≤ 2 against your real domain, and log every correction:

if ((Get-LevenshteinDistance $domain $TargetDomain) -le 2) {
    return "$local@$TargetDomain"
}

This quietly fixed submissions like [email protected] and [email protected].

Alias-style addresses

Not everyone submits their UPN. Some enter an email alias like [email protected] when their UPN is [email protected]. A direct /users/{upn} lookup returns 404.

Fall back to a filter across mail, userPrincipalName and proxyAddresses:

$filter = "mail eq '$Upn' or userPrincipalName eq '$Upn' or proxyAddresses/any(p:p eq 'smtp:$Upn')"
# requires header ConsistencyLevel: eventual

Privileged accounts with no mailbox

Many organisations issue separate admin accounts (a- or t- prefixes) that are mail-disabled. If a token is registered to one of those, the confirmation email has nowhere to go.

The solution is to separate two concepts that are usually the same:

Resolution order for the notification address:

  1. The Forms responder’s email (their real mailbox)
  2. The privileged UPN with the prefix stripped, verified in the directory
  3. If neither resolves — register anyway, skip the email, flag it in the digest

Just as important: say so in the email. Otherwise someone receiving it at their everyday mailbox will try to activate on the wrong account.

⚠️ Activation steps must differ too. A privileged user needs to open a private browser window and sign in as that account — otherwise they land on their everyday account’s security page and the token isn’t there.


10. PowerShell Traps Worth Knowing

Several of these cost real debugging time and apply well beyond this project.

Single-element arrays get unrolled

This one was genuinely nasty. A helper that returns a collection will silently degrade when the collection has exactly one item:

$items = Invoke-GraphGetAll $uri   # list has 1 item
$items.Count                        # returns 12 — the number of PROPERTIES

PowerShell unrolled the one-element array to a bare hashtable, and .Count reported its key count. The list looked like it held 12 requests; it held one.

Fix: wrap call sites in @():

$items = @(Invoke-GraphGetAll $uri)

Don’t do both return ,$items and @() at the call site — that creates a nested array and every property read returns the whole collection concatenated. We hit that too.

Graph returns hashtables, not objects

Invoke-MgGraphRequest returns Hashtable. Property-existence checks written for PSObjects silently return nothing:

# Fails for hashtables
if ($f.PSObject.Properties.Name -contains $n) { ... }

# Works for both
if ($f -is [System.Collections.IDictionary]) {
    if ($f.Contains($n)) { $v = $f[$n] }
} else { $v = $f.$n }

Response streams can only be read once

Reading a Graph response stream synchronously after the SDK has already consumed it asynchronously throws. Download to a file instead:

Invoke-MgGraphRequest -Method GET -Uri "$base/items/$id/content" -OutputFilePath $path

Paginate everything

Device lists, list items — all paginated at ~100. Once our device pool passed 100, “is this serial already registered?” started returning false negatives for older serials, which then produced conflicts on create.

[switch] vs [bool] for portal parameters

The Azure portal passes parameter values as strings. A [switch] parameter rejects "True":

param([bool]$WhatIf = $false)   # accepts "True" from the portal

SharePoint renames recreated columns

Delete a column and recreate it with the same display name, and SharePoint gives it an internal name of MyColumn1. Graph and Power Automate use the internal name.

This is why our code accepts aliases:

$COLS_UPN = @('RequesterUPN','RequesterUPN1','Email1','UPN')

Tip: the flow’s run output JSON shows the true internal names. Check there when a field mysteriously reads as empty.

Version your modules

An Automation module with no ModuleVersion shows as version 0.0, and re-uploads may be served from cache. Adding a version to the .psd1 and bumping it makes updates reliable:

ModuleVersion = '1.1.0'

Diagnose which build is actually loaded:

$cmd = Get-Command Send-RunbookEmail
$cmd.Source; $cmd.Version
$cmd.Parameters.ContainsKey('BccEmails')

Devices need a moment before binding

A freshly created device isn’t always immediately bindable — the bind returns Conflict. Add a short delay after create, retry with backoff, and verify before retrying (the bind may have actually succeeded despite the error).


11. Retry Policy: One Attempt, Then Ask the User

Our first design classified failures as transient (retry) or permanent (stop). It seemed elegant. In practice it created two problems:

We simplified to: each request is attempted exactly once.

OutcomeRecordedUser emailedRetried
SuccessRegisteredConfirmationNo
FailureFailed“Please resubmit”No

A resubmission arrives as a new response Id and is processed fresh. To requeue something manually, set its status to anything other than Registered/Failed — e.g. Retry.

This is easier to reason about, cannot loop, and puts a human in the loop exactly once.

Trade-off, stated plainly: a request that fails because the seed batch hasn’t been loaded yet will not self-heal when it arrives. Load seed files before directing users to the form.


12. Testing & Go-Live

StepActionExpected
1Run with WhatIf = trueReads everything, writes nothing
2Run live with a small batch limitOne or two users registered and emailed
3Verify in EntraUser ▸ Authentication methods shows the hardware token
4Full runRemaining users provisioned, digest sent
5ScheduleEvery 30 minutes

A -MaxUsers parameter for step 2 is well worth building — it turns a nervous first run into a controlled one.


13. Troubleshooting Reference

SymptomCauseFix
Secret parses as "0" or one characterRead the encrypted XMLParse seeds.txt instead
BadRequest on createMalformed secretConfirm Base32 decodes to 20 bytes
403 Forbidden on bindMissing directory roleAssign Authentication Administrator
Conflict on bindReplication timing, or a stale deviceAdd post-create delay + retry; delete orphaned devices
Conflict writing to a listDuplicate unique-column valueUse PATCH-or-create instead of blind POST
Item count wrong (e.g. “12” for one row)Single-element array unrolledWrap call sites in @()
Fields read as emptyHashtable vs PSObject, or renamed columnHashtable-aware reader; accept column aliases
New Forms responses never appearForms→Excel background sync removedUse Power Automate → SharePoint list
Forms trigger never firesForm is shared, not group-ownedMove to a group; ensure you’re a member
Module changes have no effectNo ModuleVersion, cached copyAdd and bump the version, re-upload
Serial “not found” for a valid tokenSeed batch not ingestedImport the archive; check vendor filename pattern

14. Security & Compliance Notes


15. Lessons Learned

If you take nothing else from this write-up:

  1. Read the data before writing the parser. Dumping one archive’s contents up front would have saved hours chasing an encrypted field.
  2. Permissions are not just permissions. Graph scopes and directory roles are separate systems; privileged writes often need both.
  3. Test with exactly one record. Most collection bugs only appear at a count of one.
  4. Never let a systemic failure notify end users. Have a circuit breaker, or a policy that cannot loop.
  5. Log the resolved values, not just the inputs. Logging which module version loaded, and which address a notification was routed to, turned several mysteries into one-line diagnoses.

Platform behaviour changes underneath you. The Forms→Excel sync change silently broke a working design. Prefer event-driven writes over polling a file.

Some user notification emails:


Built and debugged in production. Every gotcha above is one we actually hit.