Two-factor authentication breaks most unattended bots — but it doesn't have to break yours. Learn how to intercept SMS OTPs via API, extract email codes with Microsoft Graph, and generate TOTP codes programmatically to complete 2FA login flows in Power Automate Desktop without any human intervention.

Picture this: you've built a flawless unattended bot that logs into your company's legacy ERP system every morning at 6 AM, pulls the previous day's transaction data, and pushes it into a reporting database — all before your finance team arrives. It ran perfectly for three months. Then IT Security enabled mandatory two-factor authentication on every application, and your beautiful bot started failing silently at 6:01 AM every single morning. Welcome to one of the most common and least-documented problems in enterprise RPA.
Two-factor authentication (2FA) and one-time passwords (OTPs) exist specifically to defeat automation — they're designed to require a human to be present, holding a phone or reading an email. The challenge for RPA engineers is that legitimate automation still needs to get through these gates, especially for internal business processes running on systems where IT can't or won't create service accounts without MFA. The good news is that 2FA is a process, not magic, and processes can be automated if you understand where the OTP actually lives and how to get it programmatically before the time window expires.
By the end of this lesson, you'll be able to build robust, unattended login flows that handle SMS-delivered OTPs, email-based verification codes, and TOTP (Time-based One-Time Password) authenticator codes — the kind generated by apps like Microsoft Authenticator or Google Authenticator. You'll understand the architecture of each approach, the PowerShell and Python scripts that extract codes programmatically, and the error-handling patterns that prevent your bot from locking out service accounts at 3 AM.
What you'll learn:
Before working through this material, you should have solid experience with:
Before writing a single action in the flow designer, you need to understand the technical architecture of each 2FA type. The strategy changes completely depending on how the OTP arrives.
When a user triggers 2FA on an application, the application's backend calls an SMS gateway (Twilio, AWS SNS, Azure Communication Services, or a carrier-direct service). The gateway delivers a text message to the registered phone number. The OTP is valid for somewhere between 30 seconds and 10 minutes, depending on the application.
For unattended automation, you have three viable paths to intercept SMS OTPs:
Email OTP is architecturally simpler. The application sends an email to the registered address, and you read that email programmatically. The two main approaches are:
TOTP is the most technically interesting case. Apps like Microsoft Authenticator and Google Authenticator don't receive a code — they generate one using a shared secret key and the current time. The algorithm (RFC 6238) is completely deterministic: given the secret key and the current UTC timestamp, you can calculate the same 6-digit code the authenticator app would show.
This means if you have the TOTP secret key (the QR code's underlying value), you can generate codes programmatically without any phone, API call, or external service. This is the cleanest solution when it's available.
Key insight
The real challenge with TOTP isn't the cryptography — a 10-line Python script handles that. The challenge is obtaining and securely storing the TOTP secret, which must happen at enrollment time before the QR code is scanned by an authenticator app. If you miss this window, you'll need to re-enroll the service account to retrieve the secret.
The most reliable SMS interception approach for enterprise use combines a virtual phone number (via Twilio or a similar service) registered to your service account, with a lightweight Azure Function or Logic App acting as a webhook receiver.
Here's the architecture:
Let's build the polling side inside Power Automate Desktop.
Inside your desktop flow, after triggering the 2FA prompt (by submitting the username and password), add a Run PowerShell Script action. If you're not yet comfortable with the PowerShell action, see Scripting Inside Desktop Flows: Running PowerShell, Python, and VBScript Actions for the foundational setup.
# Poll the SMS OTP endpoint with exponential backoff
param(
[string]$ApiEndpoint,
[string]$ApiKey,
[string]$PhoneNumber
)
$maxAttempts = 12
$waitSeconds = 5
$otpCode = $null
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
$headers = @{
"x-api-key" = $ApiKey
"Content-Type" = "application/json"
}
$uri = "$ApiEndpoint/otp/latest?phone=$PhoneNumber"
try {
$response = Invoke-RestMethod -Uri $uri -Method GET -Headers $headers -TimeoutSec 10
if ($response.otp -and $response.timestamp) {
# Check the OTP is no older than 90 seconds
$otpAge = (Get-Date).ToUniversalTime() - [DateTime]::Parse($response.timestamp)
if ($otpAge.TotalSeconds -lt 90) {
$otpCode = $response.otp
break
}
}
}
catch {
# Log but continue polling
Write-Output "Attempt $attempt failed: $_"
}
# Exponential backoff capped at 30 seconds
$sleepTime = [Math]::Min($waitSeconds * $attempt, 30)
Start-Sleep -Seconds $sleepTime
}
if (-not $otpCode) {
throw "OTP not received within timeout window"
}
Write-Output $otpCode
In the PowerShell action configuration, set Script output variable to %OTPResult%. Then capture the output:
# In PAD flow after the PowerShell action:
SET %OTPCode% TO %OTPResult%
Warning
Never pass your API key as a hardcoded string in the script. Retrieve it from Azure Key Vault at the start of your flow and pass it as a variable. A hardcoded key in a flow script is visible in plain text to anyone with flow edit access.
SMS OTPs typically expire between 60 seconds and 10 minutes. Your polling loop above has a maximum runtime of approximately 5.5 minutes (12 attempts × averaging 15 seconds each with backoff), which covers most application timeout windows. However, there's a subtlety: by the time your flow retrieves the OTP and types it into the application, additional seconds have passed.
Always retrieve the OTP as late as possible — right before typing it — and build in logic to detect expiry messages so you can retry rather than hard-fail. We'll cover this in the error-handling section.
If your service account uses Outlook and the email OTP arrives in its inbox, you can drive Outlook directly. This approach works well in attended scenarios or unattended flows where Outlook is already open and the service account profile is loaded.
Power Automate Desktop has dedicated Outlook actions under the Email category. However, for the level of filtering you need — finding the most recent unread message from a specific sender, within the last two minutes, with a subject matching a pattern — the built-in actions are often too blunt. A PowerShell COM approach gives you precise control:
param(
[string]$SenderAddress,
[string]$SubjectContains
)
Add-Type -AssemblyName "Microsoft.Office.Interop.Outlook"
$outlook = New-Object -ComObject Outlook.Application
$namespace = $outlook.GetNamespace("MAPI")
$inbox = $namespace.GetDefaultFolder(6) # 6 = Inbox
# Search for recent messages (last 3 minutes)
$cutoff = (Get-Date).AddMinutes(-3)
$otpCode = $null
foreach ($mail in $inbox.Items) {
if ($mail.ReceivedTime -ge $cutoff -and
$mail.SenderEmailAddress -eq $SenderAddress -and
$mail.Subject -like "*$SubjectContains*") {
# Extract 6-digit OTP from email body using regex
$body = $mail.Body
$match = [regex]::Match($body, '\b(\d{6})\b')
if ($match.Success) {
$otpCode = $match.Groups[1].Value
break
}
}
}
if (-not $otpCode) {
throw "No OTP email found within the time window"
}
Write-Output $otpCode
Note
The regex \b(\d{6})\b captures any standalone 6-digit number. For applications that use 8-digit or alphanumeric OTPs, adjust accordingly. Some email templates embed the OTP in an HTML link or button — if $mail.Body returns HTML, you may need to parse $mail.HTMLBody and strip tags, or use the $mail.Body plain-text property if the email is multipart.
The Outlook COM approach has a critical weakness: it requires Outlook to be installed and a profile loaded under the service account running the bot. In many unattended environments, this isn't the case, or IT policy prevents it.
The Graph API approach is architecturally cleaner: your bot authenticates to Microsoft Graph using client credentials (app registration with Mail.Read permission on a shared mailbox), and fetches the message programmatically without needing any local email client.
param(
[string]$TenantId,
[string]$ClientId,
[string]$ClientSecret,
[string]$MailboxAddress,
[string]$SenderFilter
)
# Acquire access token using client credentials flow
$tokenUri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
$tokenBody = @{
client_id = $ClientId
scope = "https://graph.microsoft.com/.default"
client_secret = $ClientSecret
grant_type = "client_credentials"
}
$tokenResponse = Invoke-RestMethod -Method POST -Uri $tokenUri -Body $tokenBody
$accessToken = $tokenResponse.access_token
# Query inbox for recent messages from the OTP sender
$graphUri = "https://graph.microsoft.com/v1.0/users/$MailboxAddress/mailFolders/inbox/messages"
$graphUri += "?`$filter=from/emailAddress/address eq '$SenderFilter'"
$graphUri += " and receivedDateTime ge " + (Get-Date).AddMinutes(-5).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$graphUri += "&`$orderby=receivedDateTime desc&`$top=1&`$select=body,receivedDateTime,subject"
$headers = @{ Authorization = "Bearer $accessToken" }
$messages = Invoke-RestMethod -Uri $graphUri -Headers $headers
if ($messages.value.Count -eq 0) {
throw "No OTP email received in the last 5 minutes"
}
$emailBody = $messages.value[0].body.content
$match = [regex]::Match($emailBody, '\b(\d{6})\b')
if (-not $match.Success) {
throw "Could not extract OTP from email body"
}
Write-Output $match.Groups[1].Value
Store TenantId, ClientId, and ClientSecret in Azure Key Vault and retrieve them at flow start using the Key Vault action. This keeps your client secret out of the flow definition entirely.
Tip
Create a dedicated shared mailbox (e.g., rpa-otp@yourcompany.com) for your service accounts' OTP emails rather than using an individual's mailbox. This separates concerns, makes permissions easier to manage, and prevents personal email from cluttering the OTP search space.
Because email delivery isn't instantaneous, you need a retry loop around your OTP retrieval script. In PAD, structure this with a Loop action combined with conditional exit:
SET %MaxAttempts% TO 10
SET %AttemptCount% TO 0
SET %OTPFound% TO False
LOOP WHILE %OTPFound% = False AND %AttemptCount% < %MaxAttempts%
SET %AttemptCount% TO %AttemptCount% + 1
RUN PowerShell script with parameters -> %RawOTP%, %ScriptError%
IF %ScriptError% IS EMPTY THEN
SET %OTPCode% TO %RawOTP%
SET %OTPFound% TO True
ELSE
WAIT 10 seconds
END IF
END LOOP
IF %OTPFound% = False THEN
THROW "Email OTP not received after maximum attempts"
END IF
Warning
Be careful about how many failed attempts you're racking up against the application's lockout policy. If the OTP email is delayed and your bot retries the login trigger (not just the OTP retrieval), you could trigger the wrong re-send and confuse your timing logic. Only retry the email retrieval loop, not the login trigger itself.
TOTP (RFC 6238) generates codes using HMAC-SHA1 applied to a combination of the shared secret and the current time period (typically a 30-second window). The secret is encoded in Base32 and is the same value embedded in the QR code that authenticator apps scan during enrollment.
The critical moment is enrollment: when IT or the application presents the QR code for the user to scan, you need to capture the raw secret key before someone scans it with a personal authenticator app. Most applications also display the secret as a text string below the QR code for manual entry — copy this value.
Once you have the secret, you never need a phone again. Store it in Azure Key Vault and generate codes on demand.
Python's pyotp library makes TOTP generation trivial. If Python is available on your bot machine (and it should be — it's a standard enterprise tool), this is the most readable implementation:
import pyotp
import sys
import time
secret_key = sys.argv[1] # Passed from PAD as argument
# pyotp handles all the RFC 6238 math
totp = pyotp.TOTP(secret_key)
current_code = totp.now()
# Calculate seconds remaining in current window
remaining = 30 - (int(time.time()) % 30)
# If we're in the last 5 seconds of a window, wait for the next one
# This prevents submitting a code that expires before the server validates it
if remaining <= 5:
time.sleep(remaining + 1)
current_code = totp.now()
print(current_code)
Call this from a Run Python Script action in PAD, passing the secret key (retrieved from Key Vault) as the script argument. The output is your 6-digit code.
Install pyotp on your bot machine beforehand:
pip install pyotp
If you're working in a locked-down environment where Python isn't available or isn't approved, here's a pure PowerShell implementation of RFC 6238:
param([string]$Base32Secret)
function ConvertFrom-Base32 {
param([string]$Base32)
$Base32 = $Base32.ToUpper().TrimEnd('=')
$alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
$bits = ""
foreach ($char in $Base32.ToCharArray()) {
$val = $alphabet.IndexOf($char)
$bits += [Convert]::ToString($val, 2).PadLeft(5, '0')
}
$bytes = @()
for ($i = 0; $i -lt $bits.Length - 7; $i += 8) {
$bytes += [Convert]::ToByte($bits.Substring($i, 8), 2)
}
return [byte[]]$bytes
}
function Get-TOTP {
param([string]$Secret)
$secretBytes = ConvertFrom-Base32 -Base32 $Secret
$epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$timeStep = [Math]::Floor($epoch / 30)
# TOTP requires big-endian 8-byte counter
$counterBytes = [BitConverter]::GetBytes([int64]$timeStep)
if ([BitConverter]::IsLittleEndian) {
[Array]::Reverse($counterBytes)
}
$hmac = New-Object System.Security.Cryptography.HMACSHA1
$hmac.Key = $secretBytes
$hash = $hmac.ComputeHash($counterBytes)
# Dynamic truncation
$offset = $hash[19] -band 0x0F
$truncated = (($hash[$offset] -band 0x7F) -shl 24) -bor
(($hash[$offset+1] -band 0xFF) -shl 16) -bor
(($hash[$offset+2] -band 0xFF) -shl 8) -bor
($hash[$offset+3] -band 0xFF)
$otp = $truncated % 1000000
return $otp.ToString("000000")
}
# Check time remaining in current window
$epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$remaining = 30 - ($epoch % 30)
if ($remaining -le 5) {
Start-Sleep -Seconds ($remaining + 1)
}
Write-Output (Get-TOTP -Secret $Base32Secret)
Key insight
The 5-second window buffer at the end of each TOTP period is critical in unattended flows. If your bot generates a code with 3 seconds left on the clock, types it into the application over 2 seconds, and the server validates it 1 second after the window rolls over, you'll get an authentication failure even though the code was technically valid when generated. Always pad into the next window if you're close to a rollover.
Now let's put the pieces together into a production-ready flow structure. The architecture below handles all three OTP types through a subflow pattern, keeping the main login logic clean.
MAIN FLOW
├── Retrieve credentials from Key Vault
├── Launch application (browser or desktop app)
├── Enter username and password
├── Detect 2FA prompt type
│ ├── SMS → Call GetSMSOTP subflow
│ ├── Email → Call GetEmailOTP subflow
│ └── TOTP → Call GenerateTOTP subflow
├── Enter OTP code
├── Verify successful login
└── Proceed with business logic
This pattern, using subflows for modular OTP handling, follows the principles described in Subflows and Reusable Logic in Power Automate Desktop. The OTP subflows can be reused across multiple desktop flows that log into different systems.
One of the trickier parts of the complete flow is detecting which type of 2FA the application is presenting. Most legacy applications show a prompt with static text indicating the delivery method. Use a Wait for UI Element action with a timeout to detect each possible prompt, then branch accordingly.
In PAD, the pattern looks like this (using UI element existence checks):
# Try to detect SMS prompt (5 second timeout each)
UI Element Exists: [SMS verification prompt] -> %SMSPromptFound%
UI Element Exists: [Email verification prompt] -> %EmailPromptFound%
UI Element Exists: [Authenticator code prompt] -> %TOTPPromptFound%
IF %SMSPromptFound% = True THEN
CALL Subflow: GetSMSOTP
SET %OTPCode% TO %SubflowOutput%
ELSE IF %EmailPromptFound% = True THEN
CALL Subflow: GetEmailOTP
SET %OTPCode% TO %SubflowOutput%
ELSE IF %TOTPPromptFound% = True THEN
CALL Subflow: GenerateTOTP
SET %OTPCode% TO %SubflowOutput%
ELSE
THROW "Unrecognized 2FA prompt type — manual intervention required"
END IF
For selectors on legacy applications that may have fragile UI trees, review the strategies in UI Elements and Selectors in Power Automate Desktop: Building Automations That Don't Break.
Once you have the OTP code as a string variable, typing it sounds trivial — but there are subtleties:
Field behavior: Some OTP fields accept all 6 digits in one text field. Others split into individual digit boxes (common in web applications). And some legacy thick-client apps have OTP fields that don't accept keyboard paste — only keystroke-by-keystroke input.
For single-field entry:
Focus UI Element: [OTP input field]
Clear: [OTP input field]
Populate Text Field: [OTP input field] with %OTPCode%
For split digit fields (six individual boxes), loop through each character:
SET %OTPDigits% TO Split(%OTPCode%, "")
SET %BoxIndex% TO 1
FOREACH %Digit% IN %OTPDigits%
Focus UI Element: [OTP digit box %BoxIndex%]
Send Keys: %Digit%
SET %BoxIndex% TO %BoxIndex% + 1
END FOREACH
For legacy applications that reject simulated keyboard input from standard PAD actions, use the Send Keys action with physical key simulation, or fall back to clipboard-based paste. The Automating Windows Clipboard Operations in Power Automate Desktop article covers the clipboard approach in detail.
Tip
After entering the OTP, don't immediately click Submit. Add a 500ms wait first. Some OTP fields have JavaScript validation that runs on focus-out events, and clicking Submit too quickly can submit before validation completes, causing a silent failure that looks like an incorrect code.
After submitting the OTP, your flow must verify that authentication succeeded before proceeding. Don't assume success based on the prompt disappearing. Check for a positive indicator (a dashboard element, a welcome message, the application's main menu) rather than the absence of an error:
Wait for UI Element to appear: [Main application dashboard]
Timeout: 15 seconds
On timeout: THROW "Login verification failed after OTP submission"
Additionally, check for common failure states:
UI Element Exists: [Incorrect code error message] -> %OTPError%
UI Element Exists: [Account locked message] -> %AccountLocked%
IF %AccountLocked% = True THEN
# Critical: stop retrying immediately
SEND Email Alert to operations team
THROW "Service account locked — immediate intervention required"
END IF
IF %OTPError% = True THEN
# Handle incorrect OTP (can retry once)
...
END IF
The account lockout check is non-negotiable. If your bot is feeding wrong OTP codes due to a timing bug or stale cached value, it will lock the service account within 3-5 attempts on most enterprise systems. Catching this early prevents an incident.
Many applications offer to remember the browser or device after successful 2FA, skipping the OTP prompt for subsequent logins. For unattended bots, this is a double-edged sword:
The safer approach is to always expect 2FA might appear and handle it gracefully, rather than relying on session persistence. Use the detection pattern above and simply proceed normally if no 2FA prompt appears.
For browser-based applications, see Web Automation in Power Automate Desktop: Browser Actions, Form Filling, and Data Extraction for managing browser sessions and profiles in unattended contexts.
Some applications layer a CAPTCHA challenge on top of 2FA, particularly if multiple failed logins triggered a security check. This is outside the scope of programmatic solving (and ethically murky if it's a third-party CAPTCHA service). Your best options:
Microsoft Authenticator and similar apps can be configured for push notifications — instead of entering a code, the user gets a notification saying "Approve this sign-in?" This cannot be automated through Power Automate Desktop alone.
Options for push notification 2FA:
Warning
Push-based 2FA is not automatable through standard UI means. Do not attempt to automate the phone's touchscreen via an Android automation framework — this violates most terms of service and creates a massive security hole. The right solution is always a configuration change, not a technical hack.
Some applications pre-populate the OTP field with a code sent via a method your bot didn't trigger, or the previous login session left a stale OTP visible. Before entering your retrieved code, always clear the field first using the Clear action or Ctrl+A followed by Delete.
Account lockout is the most dangerous failure mode in 2FA automation. Most enterprise applications lock accounts after 3-5 consecutive failed authentication attempts, and 2FA failures typically count toward this limit.
Structure your error handling so that OTP failures are counted globally, not per-run:
# PowerShell: Read/write failure count from shared state (Azure Storage Table)
param([string]$AccountName, [string]$StorageConnectionString, [string]$Action)
$ctx = New-AzStorageContext -ConnectionString $StorageConnectionString
$tableName = "BotLoginState"
if ($Action -eq "Read") {
$entity = Get-AzTableRow -Table (Get-AzStorageTable -Name $tableName -Context $ctx).CloudTable `
-PartitionKey "OTPFailures" -RowKey $AccountName
Write-Output ($entity.FailureCount ?? 0)
}
elseif ($Action -eq "Increment") {
# Upsert failure count
$entity = @{ PartitionKey = "OTPFailures"; RowKey = $AccountName; FailureCount = 1 }
$existing = Get-AzTableRow -Table (Get-AzStorageTable -Name $tableName -Context $ctx).CloudTable `
-PartitionKey "OTPFailures" -RowKey $AccountName
if ($existing) { $entity.FailureCount = $existing.FailureCount + 1 }
Add-AzTableRow -Table (Get-AzStorageTable -Name $tableName -Context $ctx).CloudTable `
-PartitionKey $entity.PartitionKey -RowKey $entity.RowKey `
-Property @{FailureCount = $entity.FailureCount} -UpdateExisting
}
elseif ($Action -eq "Reset") {
Remove-AzTableRow -Table (Get-AzStorageTable -Name $tableName -Context $ctx).CloudTable `
-PartitionKey "OTPFailures" -RowKey $AccountName
}
In your main flow:
# Read current failure count before attempting login
RUN PowerShell: Read failure count -> %FailureCount%
IF %FailureCount% >= 2 THEN
THROW "Lockout threshold reached — halting to protect service account"
END IF
# Attempt login with OTP...
IF Login succeeded THEN
RUN PowerShell: Reset failure count
ELSE
RUN PowerShell: Increment failure count
IF %FailureCount% + 1 >= 2 THEN
SEND Alert email
END IF
THROW "OTP login failed"
END IF
This pattern stops the bot from making that fatal third attempt. Better to alert operations than to lock the account and cause a service outage.
For comprehensive error handling patterns, including On Block Error and retry policies, see Error Handling in Desktop Flows: On Block Error, Retry Policies, and Recovery Screenshots.
SMS and email delivery can lag. Carrier delays, spam filter processing, and mail server queuing all introduce latency. Your polling loops must account for this without spinning too aggressively (which wastes resources) or timing out too early (which causes false failures).
A practical polling schedule for email OTPs:
Total maximum wait: 5×3 + 10×3 + 20×4 = 125 seconds ≈ 2 minutes. This fits within most OTP validity windows while not running forever.
TOTP is time-sensitive to within ±30 seconds. If your bot machine's clock drifts more than 30 seconds from UTC, every TOTP code will be wrong and the bot will keep failing mysteriously.
Ensure your bot machine syncs to an NTP server. Add a clock check to your flow startup:
# Verify machine time is within acceptable skew of an NTP server
$ntpServer = "time.windows.com"
$ntpData = [byte[]]::new(48)
$ntpData[0] = 0x1B # NTP request header
$udpClient = New-Object System.Net.Sockets.UdpClient
$udpClient.Connect($ntpServer, 123)
$udpClient.Send($ntpData, $ntpData.Length) | Out-Null
$response = $udpClient.Receive([ref]$null)
$udpClient.Close()
$intPart = [BitConverter]::ToUInt32($response[40..43], 0)
$fracPart = [BitConverter]::ToUInt32($response[44..47], 0)
$ntpTime = [DateTime]::new(1900, 1, 1).AddSeconds(
([System.Net.IPAddress]::NetworkToHostOrder([int]$intPart)) +
([System.Net.IPAddress]::NetworkToHostOrder([int]$fracPart) / [Math]::Pow(2, 32))
)
$localTime = [DateTime]::UtcNow
$skewSeconds = [Math]::Abs(($localTime - $ntpTime).TotalSeconds)
if ($skewSeconds -gt 25) {
throw "Machine clock skew of $skewSeconds seconds detected — TOTP will fail. Sync machine time before proceeding."
}
Write-Output "Clock skew: $skewSeconds seconds (acceptable)"
Scenario: You're automating the daily reconciliation process for a financial services company. Their legacy loan management system (a thick-client Windows application from 2009) has recently enabled mandatory 2FA. The system sends a 6-digit OTP via email to rpa-reconciliation@financecompany.com — a shared mailbox your team controls. The OTP arrives within 60 seconds and is valid for 5 minutes. Your bot runs unattended at 5:30 AM daily.
Your task: Build a complete desktop flow that:
Steps to complete:
Mail.Read permission on the shared mailboxGetEmailOTP subflow containing the PowerShell Graph API script — parameterize sender address and mailbox as subflow inputsFor guidance on structuring the unattended deployment, see Attended vs Unattended RPA: Choosing a Run Mode and Configuring Machines in Power Automate.
The TOTP secret key is functionally equivalent to the account password — anyone who has it can generate valid codes indefinitely. Storing it in a flow variable that's visible in the designer, in a plain text file on the bot machine, or in a PAD environment variable without sensitivity marking is a critical security failure. Use Azure Key Vault, full stop. See the credential handling guide for implementation details: Handling Credentials Securely in Desktop Flows: Sensitive Variables and Azure Key Vault.
If your email polling finds an OTP email from a previous run (because the old email is still in the inbox), your bot enters a code that was valid 24 hours ago. Always filter emails by receivedDateTime in your Graph API query, using a 5-minute lookback window. And after a successful login, mark the OTP email as read or move it to a processed folder so it can't be mistakenly retrieved again.
Some applications show the 2FA prompt on the same page as the login form (appears after password validation, without navigation). Others redirect to a separate page or open a modal dialog. Your UI element selectors must be tested for both the element's presence and its interactable state. A prompt that's animating into view may be detectable but not yet focusable.
After submitting the OTP, don't conclude success just because no error dialog appeared. Some applications fail silently by re-displaying the login form. Always check for a positive success indicator.
If your TOTP code fails (perhaps due to clock skew), and your bot immediately generates another code and tries again, it's using the same 30-second window and will get the same code. Wait at least 31 seconds before retrying to get a fresh code — and fix the underlying clock issue before the next run.
Symptom: Your bot sends the OTP code, but the field stays empty or shows the wrong characters.
Causes and fixes:
Set Value of Element on Web Page action instead of Populate Text Field for browser-based fieldsSend Keys with the focus set to the fieldCheck these in order:
w32tm /query /status on the bot machine to check NTP sync statusBefore deploying any of these patterns, have an honest conversation with your security and compliance teams. Automating 2FA isn't inherently wrong, but it does change the risk profile:
What you're doing: Moving the second factor from "something the user physically possesses (phone)" to "something stored in a key vault." This is actually a reasonable trade-off for a service account — service accounts don't have physical possession of anything anyway.
What you must document: Your security audit trail should show that OTP secrets are stored in Key Vault with access logging enabled, that only the bot's managed identity (not individual engineers) has Get Secret permission, and that the Key Vault access is reviewed quarterly.
Regulatory considerations: In environments subject to SOX, HIPAA, or PCI-DSS, automating 2FA for service accounts often requires a formal risk acceptance sign-off. The argument that "the bot has no physical presence, so SMS/app-based 2FA is architecturally impossible for service accounts" is well-understood and generally accepted, but it needs to be in writing.
The cleanest long-term solution — and the one you should be pushing for — is service accounts that authenticate via certificate-based or service principal authentication, with MFA exempted at the platform level via Conditional Access. Automating 2FA interception is a tactical solution for systems that can't support proper service account authentication. Treat it as technical debt, not a permanent pattern.
You've now built a complete mental model and implementation toolkit for handling 2FA in unattended Power Automate Desktop flows. The three strategies — SMS gateway API polling, email OTP extraction via Graph API, and programmatic TOTP generation — cover the vast majority of 2FA configurations you'll encounter in enterprise environments. The lockout prevention pattern and clock skew detection are the production-hardening details that separate a proof-of-concept from something you'd trust to run at 5:30 AM without supervision.
The key architectural insight to carry forward: treat OTP retrieval as a service with a defined interface (call subflow, receive code string) rather than inline logic. This allows you to swap interception strategies when applications change their 2FA delivery method — and they will change it — without rewriting your core login flow.
Next steps to deepen your expertise:
The tools exist. The patterns are proven. Build it right, document the security trade-offs, and your bots will sail through MFA walls that stop lesser automations cold.