Okta Integration
LFA plugs into Okta as a step-up location factor. Okta remains the identity authority and proves who the user is. LFA proves where they are. The mechanism is Okta's IdP Authenticator: an external OIDC IdP registered with IdP Usage = "Factor only", wrapped as an authenticator, and required by an app's authentication policy.
Broker integrations need a signing key exchanged and your org's request objects pinned on the LFA side, so this one is not self-service. Work through it with us rather than in isolation. Email hello@octetproof.com and we will validate the end-to-end flow against your Okta org before you enable it for users.
How the pieces map
| Okta concept | LFA side |
|---|---|
| OIDC IdP, Factor only | LFA's /authorize, /token, JWKS endpoints |
| Signed authorize request | A broker client Octet pins to your org (see below) |
| IdP Authenticator | The "Octet UK presence" factor users see |
| App authentication policy | Where the factor becomes required |
| Username matching | The preferred_username claim, which is Okta's default (see below) |
Okta acts as a broker: it runs a fixed, standard token exchange and cannot attach custom parameters to it. Two consequences shape the whole setup.
First, Okta cannot send an octet_policy_id, so the
policy is pinned per registered client. That means one
client per policy, and therefore one IdP per policy. This guide uses a single
Octet UK presence throughout as a running example. It is a name you choose, not
a fixed value. To enforce a second region you repeat the whole setup (a new LFA
client, a new Okta IdP, its own authenticator) named for that region
(Octet US presence, and so on). Nothing below is shared between regions except
the LFA endpoints.
Second, because Okta cannot add its own fields to the token exchange, LFA
authenticates the front channel instead. Your /authorize request is
cryptographically signed, and LFA trusts the signed request to say which user is
logging in. That is the core of this integration, and it is what the Okta-side
settings below configure.
What Octet sets up for you
A broker client is configured on the LFA side by the Octet team. You do not edit any server configuration yourself.
One client, one policy. Each client we register is pinned to a single region
policy on our side (its default_policy_id), because a
broker cannot choose a policy per login. Every region you want to enforce is its
own client and its own Okta IdP, so UK presence and US presence means two clients
and two IdPs rather than one shared setup. Name all the policies you need in step
1 and we register a client for each.
Setup takes two exchanges with us, and the order is forced: Okta generates the key that signs your requests only when you create the IdP, and you cannot create the IdP without the client ID we issue.
| # | Who | What happens |
|---|---|---|
| 1 | You | Send your Okta org URL and the policy you need, for example "UK presence" |
| 2 | Octet | Issues your client_id |
| 3 | You | Create the IdP in Okta with it (below) and click Finish |
| 4 | You | On the IdP page click Download public key, send us that JWK |
| 5 | Octet | Pins the key and confirms the client is live |
| 6 | You | Finish the remaining Okta screens and test |
Step 4 means the key on the Identity Provider page, generated for this IdP.
It is not the org signing key at https://<your-org>.okta.com/oauth2/v1/keys.
Those are different keys, and Okta signs request objects with the IdP's key. If
we pin the org key, every login fails verification with an error visible only in
our logs.
Your redirect URI is not something you choose. Okta fixes it per org, and it is shown on the IdP page:
https://<your-org>.okta.com/oauth2/v1/authorize/callback
Octet pins your issuer and that signing key to your broker client. From then on
LFA accepts a signed /authorize from your org and rejects an unsigned or
wrongly-signed one. That fail-closed behaviour is the point.
LFA verifies your requests against the key Octet has pinned. It does not fetch new keys automatically (automatic refresh is a planned follow-on). If you regenerate this IdP's keypair, or delete and recreate the IdP, send Octet the new public key first (again via Download public key). Otherwise signed requests start failing verification the moment the old key stops being used, and the error appears only in our logs, not yours.
See Prerequisites for what to gather before you reach out.
Okta-side configuration
1. Identity Provider
Security → Identity Providers → Add → OpenID Connect. One per policy, using the client credentials Octet issues.
| Setting | Value |
|---|---|
| IdP Usage | Factor only |
| Client ID | The value Octet issued you |
| Authentication type | Public key / private key, letting Okta generate the keypair |
| Enable signed requests | ON (required) |
| Algorithm | RS256 |
| Require PKCE | ON |
| Application context | Off |
| IdP username | Leave at default (see step 2) |
| JIT Settings → Profile Source | Off |
Endpoints, to copy exactly. Okta does not read the discovery document, so all five are typed in by hand. They are the same for every customer:
| Okta field | Value |
|---|---|
| Issuer | https://factor.octetproof.com |
| Authorization endpoint | https://factor.octetproof.com/authorize |
| Token endpoint | https://factor.octetproof.com/token |
| JWKS endpoint | https://factor.octetproof.com/.well-known/jwks.json |
| Userinfo endpoint | https://factor.octetproof.com/userinfo |
No trailing slashes, and the issuer has no path. You can check these against the live discovery document at any time:
curl -s https://factor.octetproof.com/.well-known/openid-configuration | jq
Four of those settings need explanation.
Userinfo is not optional. Okta builds the user profile from the UserInfo response, not the id_token. Leaving it blank fails the login.
Signed requests also change client authentication. Turning them on switches
Okta to private_key_jwt at the token endpoint, signed with the keypair Okta
generates and verified by LFA against your pinned key. There is no client
secret to enter. Okta hides the field in this mode, and Octet does not issue
you one for a broker client.
RS256 is required. Okta verifies only RS256 and rejects LFA's default ES256.
Leave JIT off. JIT provisioning lets an external IdP create or update Okta
user profiles. LFA is a factor, not an identity source, and Okta remains the
authority on who the user is. It matters practically too: LFA emits email with
email_verified: false, because that value is the hint your org signed rather
than an address we verified. With Profile Source enabled, that unverified value
could overwrite the real email on the Okta user record. Left off, the claim is
only ever a matching input.
2. Username matching
Leave IdP Username at its default.
A Factor-only IdP always matches on idpuser.preferredUsername and ignores
whatever you type in that field. LFA emits preferred_username for broker
clients precisely so the default works.
You do not need a Profile Editor attribute, and a custom expression has no
effect. If you set one, Okta still evaluates preferredUsername, and the login
fails with a message that does not mention usernames at all.
3. The authenticator
The IdP by itself is inert. Okta offers it to users only once it is registered as an authenticator.
Security → Authenticators → Setup tab → Add authenticator → Identity
Provider. Select the IdP you just made, and give it the name users will see at
sign-in, for example Octet UK presence. Save.
4. Enrollment policy
Security → Authenticators → Enrollment tab → edit the policy that covers your users (the Default Policy, unless you have added others) → set the new authenticator to Optional. Save.
Without this the authenticator exists but no one can use it, and it does not appear in the policy picker in the next step.
5. The app policy
This is where the factor gets exercised. You need an app to sign in to and a policy that forces the factor for that app.
a. An app. If you are only testing, make a throwaway one:
Applications → Create App Integration → OIDC → Web Application. Give it any
redirect URI. A dead http://localhost:8080/authorization-code/callback is fine,
and you never need a server there. Note two things from its General tab: the
Client ID, and the exact Sign-in redirect URI Okta saved. It may differ
from what you typed, so copy it verbatim. You need it to launch the test.
With Federation Broker Mode on (the default for a new OIDC app), the app is implicitly assigned to all users and access is governed entirely by its sign-on policy, so there is no separate assignment step. If you turned that off, assign yourself under the app's Assignments tab. Otherwise the policy below never runs and the login silently skips the factor.
b. A policy that forces Octet. Security → Authentication Policies → Add a policy, pick the app sign-in policy type (labelled "OIDC" or "Okta Sign-On", not account-management). A new policy comes with one Catch-all Rule. Edit that rule rather than adding another:
- User must authenticate with: Password + Another factor
- Possession factor constraints are: uncheck every box. A new rule ships with Device Bound required, and an external-IdP factor is not device bound, so leaving it ticked silently removes Octet from the options.
- Authentication methods: Allow specific authentication methods, and add exactly Password and Octet UK presence, nothing else.
That last setting is what forces the factor, and it is easy to get wrong:
If the rule allows any second factor (the default), Okta satisfies it with whatever the user already has enrolled, usually Okta Verify, and never prompts for Octet at all. The login looks like 2FA while the location factor stays optional. Restricting Authentication methods to Password and Octet is what makes presence mandatory.
c. Attach the app to the policy. Easy to miss, and nothing works without it: on the policy's Applications tab, Add app and pick the app from (a). A policy governs nothing until an app points at it.
Setup checklist
- The authenticator is Active on Authenticators → Setup.
- Your test app is listed on the policy's Applications tab.
- The rule's Authentication methods list is exactly Password and Octet UK presence, and every possession-factor constraint is unchecked.
Testing the login
Launch the app's sign-in directly at Okta. No running app is needed behind it. Build this URL, substituting your app's Client ID and the exact redirect URI from step 5a:
https://<your-org>.okta.com/oauth2/v1/authorize?client_id=<CLIENT_ID>&response_type=code&scope=openid+profile+email&redirect_uri=<REDIRECT_URI>&state=test&nonce=test
Open it in a fresh private or incognito window. Expected sequence:
- Okta login: username and password.
- "Verify with Octet UK presence". On the first run Okta enrols it in the same step. If you instead see only Okta Verify and Password, the rule is not forcing the factor, so recheck step 5b.
- The app redirects to your phone, and the phone proves presence.
- The browser lands on your dead redirect URI showing
…/callback?code=…&state=test.
That final ?code=… on a dead page is success. It means Okta completed the
whole exchange and issued an authorization code. The connection error is expected,
because you never ran a server there.
Okta snapshots the policy at the moment a sign-in begins and holds it for the whole flow. A tab you started before a change keeps the old rules no matter how many times you save. Always retest in a fresh private window, never the error screen's Verify button, which resumes the old transaction.
What travels where
-
Okta → LFA
/authorize. Okta signs the request as a JWS request object (request=<JWS>). LFA verifies it against the key pinned to your broker client. The issuer, audience, and expiry must check out, and the parameters inside the signed object take precedence over anything in the URL. Thelogin_hintinside that signed object is the user LFA binds the device to. A signed request that carries nologin_hintis rejected. -
Phone → LFA. A signed location proof plus Apple App Attest, carrying a region verdict only, no raw coordinates. See Trust & Privacy.
-
LFA → Okta. An RS256 id_token (verifiable against LFA's JWKS) with:
acr: urn:octet:loc:strong, meaning hardware-attested presence with the policy passed.octet_location_assertion, the signed region-verdict statement.preferred_username, the hint from the signed request. This is what Okta matches the user on.octet_login_hint, plusemailwithemail_verified: falsewhen the hint is an address. The same claims are returned from/userinfo, which is where Okta reads them.
The device binds to the login_hint from the signed request, never a raw
value from the browser's URL. A tampered front-channel request therefore cannot
bind a victim's account: editing the hint breaks the signature, and the request
is rejected.
Device replacement and recovery
A broker login binds the user's phone on first success, the same as any LFA login
(see Device Lifecycle). Because Okta cannot send
device-enrollment fields on the token exchange, a broker user's new or
different phone is rejected (invalid_grant) rather than silently rebinding.
To move a user to a new phone, ask Octet to revoke the binding
(POST /v1/device/revoke). The user's next login then binds the replacement
phone automatically.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
/authorize rejected as an unsigned or invalid request |
Signed requests are off, or the key Octet pinned is not the one Okta is signing with | Turn Enable signed requests on. Otherwise re-send the key from Download public key on the IdP page, not /oauth2/v1/keys, which is a different key and a common mix-up |
| "Octet UK presence" never appears at the verify screen, only Okta Verify and Password | The rule is not forcing it: it allows any 2 factors, a possession-factor constraint (Device Bound) is filtering the external-IdP factor out, or (with Federation Broker Mode off) you are not assigned to the app | Step 5b: set Authentication methods to Allow specific → Password + Octet, uncheck all possession constraints, and confirm the app is assigned |
Authorization error: signed request object failed verification on LFA's page |
The public key on file does not match the one Okta is signing with | Re-send the key from Download public key on the IdP page so Octet can re-pin it. This mismatch does not show up in Okta's own logs |
| Login rejected with no bind user | The signed request reached LFA without a login_hint |
Ensure Okta is sending login_hint on the authorize. A broker request without it is rejected |
| "Unable to verify authenticator", or "The UserInfo response is invalid" | Okta reports several unrelated failures with these two strings, and neither describes the real cause | Open Reports → System Log, expand the Authenticate user via IDP failure, and read debugContext.debugData.errorMessage. It names the exact check that failed. The summary text does not |
| Changing IdP Username makes no difference | Expected. A Factor-only IdP ignores that field | Leave it at the default. Matching is always on preferred_username |
| Login fails immediately after a config change, but the settings look right | An in-flight sign-in keeps the configuration it started with | Retest from a fresh private window, not the error screen's Verify button |
User's new phone rejected (invalid_grant) |
The user is already bound to a previous device | Ask Octet to revoke the binding. The next login rebinds the new phone |
| QR code or login expires before completion | The login session (90 s) or the authorization code (60 s) lapsed | Restart the login. Presence proofs are deliberately short-lived |
Where to go next
- Policies. What the pinned policy enforces.
- Device Lifecycle. Binding, revocation, and new phones on the broker path.
- Direct OIDC integration. The same endpoints without
a broker, where your token exchange sends
octet_user_iddirectly.