SurePassID Offline OTP and Offline FIDO2
SurePassID Authentication Server
Offline Authentication: Offline OTP and Offline FIDO2
Summary
The SurePassID Windows Login Manager enforces MFA at the Windows logon, unlock, and UAC screens. When the SurePassID MFA server is unreachable (network outage, remote/travelling laptop, isolated machine), two offline mechanisms allow the second factor to still be verified locally:
- Offline OTP — a cache of pre-computed one-time passcodes is stored on the machine after a successful online authentication and consumed one-at-a-time while offline.
- Offline FIDO2 — FIDO2 credential descriptors and public keys are cached after successful online authentication, and security-key assertions are verified locally (signature + counter) while offline.
Both caches are populated only during a successful online authentication, are encrypted with Windows DPAPI, are policy-gated, and emit Windows Event Log security events for monitoring.
Online vs. offline flow
The provider first checks whether the MFA server is reachable
(SPUtil::IsMfaServerReachable). That single decision routes
the request down the online or offline path. The status line on the tile
reflects the result (see §1.4).
flowchart TD
A[User enters username + password] --> B{MFA server reachable?}
B -- Yes --> C[ONLINE path]
C --> C1[Validate 2nd factor with server<br/>OTP / Push / FIDO2]
C1 --> C2{Success?}
C2 -- No --> CF[Deny logon<br/>MSG_AUTHENTICATION_FAILED]
C2 -- Yes --> C3[Refresh local cache]
C3 --> C3a[OTP: if AllowOffLineAuth=0 and<br/>server returned codes -> SaveOfflineCache<br/>DPAPI-encrypt OfflineCache]
C3 --> C3b[FIDO2: if AllowOfflineFido2=1 -><br/>SaveFromOptionsResponse + SavePublicKey<br/>DPAPI-encrypt Fido2OfflineCredentials]
C3a --> COK[Serialize credential -> logon]
C3b --> COK
B -- No --> D[OFFLINE path<br/>MSG_MFA_SERVER_UNREACHABLE]
D --> E{Offline mode<br/>AllowOffLineAuth}
E -- "0 (Offline 2FA)" --> F{Which cached factor?}
F -- FIDO2 enabled + cached --> F1[VerifyAssertionLocally<br/>signature + counter + RP/UP]
F -- OTP cache present --> F2[ValidateOffLineOtp<br/>match code, advance index]
F1 --> G{Valid?}
F2 --> G
G -- No --> GF[Deny / exhausted<br/>MSG_OFFLINE_CACHE_EXHAUSTED]
G -- Yes --> GOK[Serialize credential -> logon]
E -- "2 (Offline 1FA)" --> H[Password-only fallback<br/>no 2nd factor offline]
H --> GOK
E -- "1 (Online 2FA only)" --> I[No offline fallback<br/>Deny until online]
Notes:
- Offline OTP is only available in mode
0(OFFLINE_OPTION_OFFLINE2FA). - Offline FIDO2 is controlled separately by
AllowOfflineFido2and works whenever a FIDO2 cache exists, independent of the OTP mode. - The cache is written only on the ONLINE success path; the offline path only consumes it (advancing the OTP index / bumping the FIDO2 counter).
1. How it works
1.1 Why the cache lives in HKLM and is machine-bound
At the logon screen the credential provider runs as
SYSTEM, so HKCU resolves to the SYSTEM
profile, not the user signing in. The offline caches are therefore
stored under HKLM\SOFTWARE\SurePassId\CredProv and
encrypted with DPAPI using CRYPTPROTECT_LOCAL_MACHINE,
which binds the ciphertext to the machine (not a user session) so SYSTEM
can decrypt it at the logon screen.
Registry layout:
HKLM\SOFTWARE\SurePassId\CredProv
OfflineCache = <DPAPI-encrypted, comma-separated OTP codes>
OfflineCacheIndex = <integer: index of next unused code>
Fido2OfflineCredentials = <DPAPI-encrypted JSON blob>
Fido2OfflineLastSync = <Unix timestamp of last online FIDO2 auth>
See
docs/PerUserOfflineCache_DesignDoc.mdfor the per-user cache design that addresses shared-machine scenarios.
1.2 Offline OTP
Populate (online): After a successful online
authentication the server returns a set of future OTP values.
CSurePassClientLib::SaveOfflineCache encrypts them with
DPAPI (SPUtil::EncryptForCache) and writes them to
OfflineCache, resetting OfflineCacheIndex as
appropriate.
Consume (offline):
CSurePassClientLib::ValidateOffLineOtp:
- Reads and DPAPI-decrypts
OfflineCache(SPUtil::DecryptFromCache, which also transparently handles the legacy cache format). - Reads
OfflineCacheIndex(the first still-valid code). - If
index >= count, the cache is exhausted — a security event is raised and offline auth is denied until the user re-authenticates online. - Scans from
indexforward for a matching code. On a match it advancesOfflineCacheIndextomatched + 1so each cached code is single-use (a used or skipped code can never be replayed).
Format: OfflineCache is a
comma-separated list of OTP codes; a monotonic
OfflineCacheIndex marks consumption.
1.3 Offline FIDO2
Implemented in SPWebAuthnLib/SPFido2OfflineCache.
Populate (online):
SaveFromOptionsResponsecaches the relying-party ID and credential descriptors (credential IDs, type, transports) from the assertion options.SavePublicKeycaches the COSE public key and algorithm (e.g.-7ES256,-257RS256) for each credential returned by the server, preserving thelastCounter.- The whole JSON blob is DPAPI-encrypted and stored in
Fido2OfflineCredentials;Fido2OfflineLastSyncrecords the sync time.
Consume (offline):
LoadCachedOptionsResponsesynthesizes an assertion-options response and generates a fresh 32-byte random challenge withBCryptGenRandom.VerifyAssertionLocallyverifies the authenticator assertion using CNG (BCryptVerifySignature):- Parse
authenticatorData,clientDataJSON,signature,credentialId. - Look up the cached public key + algorithm for that credential.
- Compute
SHA-256(clientDataJSON). signed_data = authenticatorData || client_data_hash.- Verify the RP ID hash (first 32 bytes of
authenticatorData). - Check the User Present (UP) flag.
- Counter anti-replay: the assertion counter must be
greater than the cached
lastCounter(unless the authenticator reports0, meaning it does not implement counters). A counter regression is rejected. - Verify the signature; on success update
lastCounter.
- Parse
Decrypted cache JSON (illustrative):
{
"rpId": "mfa.surepassid.com",
"credentials": [
{
"id": "<base64url credential ID>",
"type": "public-key",
"publicKey": "<base64url public key>",
"algorithm": -7,
"transports": ["usb", "nfc"],
"lastCounter": 42
}
],
"cachedAt": "1719500000"
}1.4 Online/offline status shown in the UI
The credential tile displays a status line
(SFI_LOGONSTATUS_TEXT) so the user immediately knows
whether they are online and, when offline, which second factor is
available. It is set during Initialize and refreshed by
UpdateLogonStatusText; the text comes from
GetLogonStatusText, which checks server reachability plus
the enabled offline methods and their cache state:
| Condition | Status text shown |
|---|---|
| MFA server reachable | Server: Online |
| Offline, FIDO2 cache and OTP cache available | Offline: Security key or passcode |
| Offline, FIDO2 cache available | Offline: Security key |
Offline, OTP cache available
(AllowOffLineAuth = 0) |
Offline: Passcode required |
Offline, single-factor mode (AllowOffLineAuth = 2) |
Offline: Single-factor access |
| Offline, no offline method available | Offline: No access available |
The offline options reflected here are:
SPFido2OfflineCache::IsOfflineFido2Enabled() +
HasCachedCredentials() for FIDO2, and
GetOfflineOtpOption() +
CSurePassClientLib::HasOfflineOtpCache() for OTP.
Offline: No access available means offline auth is not
configured or the machine has no cache yet (no prior online
sign-in).
1.5 When and how the local cache is updated
The caches are only written during a successful online authentication — there is no background refresh. Each online sign-in replaces (not appends to) the relevant cache.
Offline OTP cache update:
- Trigger: a successful online OTP
validation (
ValidateUserOtpinSurePassIdCredential.cpp), and the offline mode isOFFLINE_OPTION_OFFLINE2FA(AllowOffLineAuth = 0), and the server returned a non-emptyoffline_codeslist in the validation response. - How: the server-supplied future codes are passed to
CSurePassClientLib::SaveOfflineCache, which DPAPI-encrypts them (SPUtil::EncryptForCache) and writesOfflineCache. The consumption pointerOfflineCacheIndextracks how many have been used since. - If the mode is not
OFFLINE_OPTION_OFFLINE2FA, no OTP cache is written (and any existing one is invalidated at next logon — see §3.3).
Offline FIDO2 cache update (two steps, both online):
- Descriptors — on a successful online
assertion options call
(
ServerPublicKeyCredentialGetOptions,error_code == 0) and only whenIsOfflineFido2Enabled(),SaveFromOptionsResponsecaches the RP ID and the allowed credential descriptors (IDs, type, transports). - Public key — on a successful online
assertion result (
Fido2AssertionResult) when the server returns anofflineCredential,SavePublicKeystores/updates that credential's public key, algorithm andlastCounter.
Both steps DPAPI-encrypt the JSON blob into
Fido2OfflineCredentials and update
Fido2OfflineLastSync.
Consumption vs. update:
- Using an offline OTP advances
OfflineCacheIndex(each code single-use); it does not add new codes — only a later online sign-in refills them. - Using an offline FIDO2 key updates that credential's
lastCounter(replay protection); the cached descriptors/public key are otherwise refreshed only on the next online sign-in.
Invalidation (removal) happens at the start of a logon/unlock session when policy no longer permits the method — see §3.3.
2. Configuration
All values are read through
SPRegistry/ConfigurationManager, so a value
set via Group Policy
(HKLM\SOFTWARE\Policies\SurePassId\CredProv) overrides the
local value. See docs/GPO_SETUP_GUIDE.md.
2.1 Offline OTP —
AllowOffLineAuth
AllowOffLineAuth is a tri-state that maps to the offline
OTP option via
ConfigurationManager::GetOfflineOtpOption():
AllowOffLineAuth |
Mode constant | Meaning |
|---|---|---|
"0" |
OFFLINE_OPTION_OFFLINE2FA |
Offline two-factor using the cached OTP codes (OTP cache required). |
"1" (default) |
OFFLINE_OPTION_2FA |
Online two-factor; offline OTP cache not used for fallback. |
"2" |
OFFLINE_OPTION_1FA |
Offline single-factor fallback (password only when offline); OTP cache not used. |
When the effective mode does not require the OTP cache, any existing cache is proactively invalidated (see §3.2).
2.2 Offline FIDO2 —
AllowOfflineFido2
| Value | Meaning |
|---|---|
AllowOfflineFido2 = "1" |
Offline FIDO2 verification enabled. |
"0" or absent (default) |
Offline FIDO2 disabled (secure by default); any cache is invalidated. |
SPFido2OfflineCache::IsOfflineFido2Enabled() reads this
flag (REG_ALLOW_OFFLINE_FIDO2). It is
independent of AllowOffLineAuth — OTP and
FIDO2 offline are controlled separately.
2.3 Related settings
| Value | Purpose |
|---|---|
DisableMfaServerConnectivity |
Controls the reachability check that decides whether the online or offline path is taken. |
TraceOfflineCache |
Enables verbose tracing of offline-cache operations. |
3. Security
3.1 Encryption at rest
- Both caches are encrypted with DPAPI
(
CRYPTPROTECT_LOCAL_MACHINE) plus an entropy string, so the ciphertext is bound to the machine and unreadable if copied elsewhere. SPUtil::EncryptForCache/SPUtil::DecryptFromCachecentralize encryption.DecryptFromCachealso accepts the legacy format for transparent migration; new writes are always DPAPI.- If DPAPI encryption fails, a
MSG_DPAPI_ENCRYPTION_FAILEDsecurity event is raised and the code falls back to the legacy scheme so the user is not locked out.
3.2 Anti-replay and single-use
- OTP:
OfflineCacheIndexadvances past every matched code, so each cached OTP is strictly single-use and cannot be replayed. When the list is exhausted, offline auth is denied until the next online sync. - FIDO2: the assertion signature
counter must strictly increase over the cached
lastCounter; regressions (a sign of a cloned authenticator) are rejected. The RP ID hash and User Present flag are also validated, and the signature is verified cryptographically with CNG.
3.3 Policy enforcement / cache invalidation
If offline authentication is later disabled by policy, stale caches are removed so they cannot be used:
CSurePassClientLib::InvalidateOtpCacheIfDisabled()clears the OTP cache when the effective mode does not use it (ClearOtpCachezeroesOfflineCacheand resetsOfflineCacheIndex).SPFido2OfflineCache::InvalidateCacheIfDisabled()clears the FIDO2 cache whenAllowOfflineFido2is disabled (ClearCache).
These run at the start of each logon/unlock session from
SurePassIdProvider::SetUsageScenario
(CSurePassClientLib::InvalidateOtpCacheIfDisabled and
SPFido2OfflineCache::InvalidateCacheIfDisabled), so a
policy change takes effect on the next logon without requiring an online
round-trip.
3.4 Secure by default
- Offline FIDO2 is off unless explicitly enabled.
- The caches only ever contain future OTP values / FIDO2 public keys (public data) — never passwords or FIDO2 private keys.
4. Monitoring
Offline authentication is auditable through the Windows Event Log (source SurePassID WLM, category Security). Relevant events:
| Event | ID (hex) | Meaning |
|---|---|---|
MSG_OFFLINE_AUTH_ATTEMPTED |
0x400207D1 |
An offline authentication was attempted. |
MSG_OFFLINE_CACHE_EXHAUSTED |
0x800207D2 |
The offline OTP cache is used up; the user must authenticate online to replenish it. |
MSG_OFFLINE_CACHE_INVALIDATED |
0x800207DA |
An offline cache (OTP or FIDO2) was invalidated because offline authentication was disabled by policy. |
MSG_MFA_SERVER_UNREACHABLE |
0x800207D3 |
The MFA server could not be reached (triggers the offline path). |
MSG_DPAPI_ENCRYPTION_FAILED |
0x800207D8 |
DPAPI encryption of a cache failed; fell back to the legacy scheme. |
MSG_AUTHENTICATION_SUCCEEDED /
MSG_AUTHENTICATION_FAILED |
0x400207D6 / 0xC00207D5 |
Final authentication result (covers both online and offline paths). |
Additional diagnostics: set TraceOn = 1 (and optionally
TraceOfflineCache = 1) to capture detailed offline-cache
tracing in the SurePassID log. Sensitive values (OTP codes, cache
contents) are masked unless trace level is explicitly raised.
What to alert on
MSG_OFFLINE_CACHE_EXHAUSTED— users repeatedly hitting this may indicate prolonged connectivity loss; they must reconnect to replenish.MSG_OFFLINE_CACHE_INVALIDATED— confirms a policy tightening took effect; unexpected occurrences warrant review.MSG_DPAPI_ENCRYPTION_FAILED— indicates a platform/DPAPI problem worth investigating.- FIDO2 counter regression (logged as an error during
VerifyAssertionLocally) — possible cloned-authenticator indicator.
5. Component reference
| File | Responsibility |
|---|---|
SurePassIdLib/SPRegistry.* |
Registry read/write; GPO precedence. |
ConfigurationManager.* |
GetOfflineOtpOption() and related config reads. |
CSurePassClientLib.* |
OTP cache: Save, Validate, HasCache, Clear, Invalidate. |
SurePassIdLib/SPUtil
(EncryptForCache/DecryptFromCache) |
DPAPI encryption of caches. |
SPWebAuthnLib/SPFido2OfflineCache.* |
FIDO2 cache: Save, Load, VerifyAssertionLocally, Clear, Invalidate. |
SurePassIdProvider.cpp |
Invalidates caches at session start. |
SurePassIdCredential.cpp |
Drives OTP validate/save and offline status UI. |
SurePassIdLib/SPEventMessages.h |
Security event definitions. |
6. Related documents
docs/PerUserOfflineCache_DesignDoc.md— per-user offline cache design.docs/CONFIGURATION.md— full configuration reference.docs/GPO_SETUP_GUIDE.md— managing settings via Group Policy.docs/ENFORCE_ADMIN_MFA.md— how admin MFA enforcement interacts with offline fallback.
© 2013–2026 SurePassID. All rights reserved. Protected by patents pending. SurePassID, the SurePassID logo and design, and Secure SSO are registered trademarks or trademarks of SurePassID, Corp. in the United States and/or other jurisdictions. All other marks and names mentioned herein may be trademarks of their respective companies.
SurePassID 360 Central Avenue #800 St. Petersburg, FL 33701 USA +1 (888) 200-8144 surepassid.com