How a Login Works
LFA is an OIDC identity provider that issues tokens only after the user's phone produces a hardware-attested proof of presence in the region your policy names.
Components
- Your application (or your broker) starts the login and consumes the tokens.
- The browser shows a QR code and waits.
- The Octet Location app runs the location check on the phone and produces the proof. It embeds the Octet SDK, which is what measures and signs proof of location.
- LFA (
factor.octetproof.com) checks the proof (how verification works) against your policy, then answers over standard OIDC.
The flow
flowchart TD
A[Your app redirects to /authorize] --> B[Browser shows QR code / app link]
B --> C[User scans with the Octet Location app]
C --> D[App runs the location check<br/>signed proof, region verdict only]
D --> E[Phone submits proof to LFA<br/>with a hardware attestation]
E --> F{LFA verifies:<br/>attestation, freshness, policy}
F -->|pass| G[Browser returns to your app with a code]
F -->|fail| H[access_denied]
G --> I[Your app exchanges the code + octet_user_id]
I --> J[id_token with acr urn:octet:loc:strong<br/>+ signed location assertion]
Step by step:
- Your app redirects to
/authorizeexactly as it would for any external OIDC provider (code flow, PKCE,state,nonce). - The browser lands on the LFA login page (a QR code, or a tap-to-open app link on the phone itself) and waits on a live status stream.
- The user scans the code. The app fetches the session's parameters, including a server-issued one-time challenge the proof must commit to.
- The app runs the Octet location check on the phone and produces a signed proof whose payload is a region verdict, never coordinates.
- The phone submits the proof with a hardware attestation. LFA verifies the whole chain fail-closed (signature, device identity, platform attestation, freshness, and the policy) and pushes the result to the waiting browser.
- The browser returns to your app with an authorization code. You exchange it
(with
octet_user_id) for the tokens.
Direct integrations and brokers
The flow above shows a direct integration: your app authenticates the
/token exchange with its client secret and sends octet_user_id there. That
is the identifier LFA binds the attested device to (see
Device lifecycle).
Identity brokers such as Okta run a fixed, standard token
exchange and cannot add octet_user_id, so they bind over the front channel
instead. A broker authenticates the /authorize request with a signed request
object (RFC 9101, a JAR passed as
request=<JWS>). LFA verifies that object against the broker's pinned signing
keys and binds the device to the login_hint carried inside the verified
object.
In both cases the user id LFA binds to arrives over an authenticated channel: a
client-secret-authenticated /token exchange, or a signature-verified
/authorize object. It is never read from the raw browser redirect.
Front-channel tampering (a browser rewriting login_hint to a victim's id)
breaks the signature, and the request is rejected.
Direct clients are unaffected by broker handling. A stray request= parameter
on a non-broker client is ignored, and direct clients keep using
octet_user_id and octet_device_enroll on the RP-authenticated /token.
See Okta for the broker setup, and Device lifecycle for how binding and device replacement work in each mode.
First login versus later logins
The first time a phone is used, two one-time steps happen inside the same flow, so the first login takes slightly longer:
- The app creates a signing key in the phone's secure hardware and has the platform certify it to LFA: Apple App Attest on iOS, hardware key attestation chained to a Google root on Android. This is device enrollment.
- Your token exchange binds that device to the
octet_user_idyou sent (see Device lifecycle).
Every later login reuses the enrolled key. The phone produces a fresh hardware signature over this session's challenge, and LFA checks it against the key and binding on file. There is no re-enrollment and no extra prompt.
Push for enrolled devices
Once a device is enrolled and bound to a user, LFA can reach that phone directly.
If your /authorize request identifies the user in a verified way (a broker
client's signed request object carries them in login_hint), LFA sends a
push notification to their bound phone instead of expecting a scan:
Location check requested
Acme is requesting a location check. Tap to review.
The user taps the notification, sees the same consent screen, and approves, without scanning a QR code. A broker login session lasts 180 seconds, and a short per-user cooldown stops repeated requests piling up on someone's lock screen.
The QR code remains the fallback: when the request does not identify the user
(including every direct-client login, whose login_hint is unverified), the
device is not enrolled yet, or the push does not arrive. Your integration
code is the same either way. Only the delivery of the session to the phone
differs.
Why a captured proof cannot be reused
Everything in the flow is single-use and short-lived:
- The proof commits to a per-session server challenge, signed inside the proof itself, so it cannot be replayed into any other login. The guarantee holds wherever the proof travels.
- The login session lives 90 seconds, or 180 seconds for a broker login that can be pushed to a bound phone. The authorization code lives 60 seconds and can be redeemed once. The location assertion lives 120 seconds.
- The proof must be fresh when LFA checks it (see Policies).
Nothing in the flow can serve as a long-lived credential.
Where to go next
- Policies. What "pass" means: country, attestation, freshness, and anti-spoof tier.
- Device lifecycle. Enrollment, binding, new phones, lost phones.