OIDC Reference
The complete wire contract: endpoints, protocol requirements, parameters, claims,
and errors. Discovery is live at
https://factor.octetproof.com/.well-known/openid-configuration.
Endpoints
| Endpoint | URL |
|---|---|
| Issuer | https://factor.octetproof.com |
| Authorization | https://factor.octetproof.com/authorize |
| Token | https://factor.octetproof.com/token |
| UserInfo | https://factor.octetproof.com/userinfo |
| JWKS | https://factor.octetproof.com/.well-known/jwks.json |
| Device revocation (LFA extension) | https://factor.octetproof.com/v1/device/revoke |
Protocol requirements
LFA implements the authorization-code flow, deliberately narrow and strict:
| Requirement | Value |
|---|---|
| Response type | code only |
| Grant | authorization_code only, with no refresh tokens |
| PKCE | S256 required. An /authorize request without a code challenge is rejected |
state and nonce |
Both mandatory (stricter than the OIDC spec, which only recommends state) |
| Client authentication | client_secret_post, client_secret_basic, or private_key_jwt (RFC 7523 §2.2, broker clients, verified against the same pinned keys as the request object) |
| Authorization code | Single-use (enforced atomically), 60-second redemption window |
| id_token signing | ES256 by default, RS256 available per client. Both keys are published at the JWKS endpoint with distinct kids, and the id_token header names the one that signed it. Ask us to switch your client to RS256 if your stack cannot verify EC signatures. Okta, for example, verifies only RS256 |
| Scopes | openid and octet.location, the only scopes advertised. Extras a library adds (profile email) are ignored |
| UserInfo | Accepts the access token only. An id_token or location assertion presented as a bearer token is rejected (401) |
/authorize parameters
Beyond the standard authorization-code parameters:
| Parameter | Purpose |
|---|---|
octet_policy_id |
Selects which registered policy this login must satisfy. Omit for the client's pinned default. An unknown id is rejected |
login_hint |
Optional and unverified, echoed back as the octet_login_hint claim |
request |
A signed request object (RFC 9101 JAR). Required for broker clients, ignored for direct clients. See below |
Signed request objects (brokers)
Identity brokers (Okta's IdP-Authenticator "Factor only", Microsoft Entra ID,
Auth0) run a fixed, standard token exchange and cannot add the octet_user_id
that a direct client sends at /token. A broker instead authenticates the front
channel: it signs its /authorize request as a JWS and passes it as
request=<JWS>, and the user it binds is taken from inside that signed object.
A client is a broker only when Octet has configured a server-side broker section
for it, holding an expected issuer and a set of pinned request-signing public
keys that you supply to Octet. This is fail-closed: once a client is a broker, an
/authorize without a valid signed object is rejected. For a direct client a
stray request= is ignored.
| Check | Rule |
|---|---|
| Verification | Verified against the broker's pinned keys only, with no network key fetch. RS256 and ES256 only. none and HS* are rejected |
iss |
Must equal the configured broker issuer |
aud |
Must equal LFA's own issuer (https://factor.octetproof.com) |
exp / nbf |
Must be valid, in a strict 3-segment JWS |
client_id |
The object's client_id must equal the query client_id (RFC 9101 §5) |
login_hint |
Required inside the object. Becomes the broker-authenticated bind user. An object with no login_hint is rejected |
Parameters inside the signed object take precedence over the query string:
response_type, redirect_uri, scope, state, nonce, the PKCE challenge
and method, acr_values, and octet_policy_id.
The device binds only to the login_hint from the signed object, never the
raw front-channel query login_hint. A browser that edits the query login_hint
to a victim's id breaks the signature, so the request is rejected. See
Okta integration for the broker setup.
/token parameters
Beyond the standard code-exchange parameters (grant_type, code,
redirect_uri, client credentials, code_verifier):
| Parameter | Purpose |
|---|---|
octet_user_id |
Required in production for direct clients. Your stable, opaque identifier for the user signing in. Drives device-to-user binding |
octet_device_enroll |
Optional. true replaces the user's bound device with the one that just proved presence, revoking the old device. Send only after your own step-up verification |
There are two binding sources, depending on the client:
- Direct clients send
octet_user_idon the RP-authenticated/token, as documented above, and useoctet_device_enrollfor device replacement. - Broker clients bind on the
login_hintfrom the signed request object at/authorize(see Signed request objects) and cannot sendoctet_user_idoroctet_device_enroll. Enrollment is forced off, so a new or different device for an already-bound user is rejected (invalid_grant). Recovery is an admin revoke viaPOST /v1/device/revoke, after which the next login TOFU-binds the new phone. A broker session that reaches/tokenwith no authenticated hint is rejected regardless of App Attest.
The token response:
{
"token_type": "Bearer",
"access_token": "…",
"id_token": "…",
"location_assertion": "…"
}
location_assertion is a top-level mirror of the octet_location_assertion
claim, for stacks that do not want to open a nested JWT.
id_token claims
| Claim | Meaning |
|---|---|
iss |
https://factor.octetproof.com |
sub |
The bound user id, returned verbatim: the octet_user_id you supplied at /token for a direct client, or the signed-request login_hint for a broker client |
aud |
Your client_id |
exp, iat |
Standard timestamps |
auth_time |
When the location proof was accepted, which is the moment the user authenticated |
nonce |
Echo of your nonce. Verify it |
acr |
urn:octet:loc:strong, meaning the proof was hardware-verified (Apple App Attest) and the policy passed |
amr |
["octet-loc"] |
token_use |
id (the access token carries access) |
octet_login_hint |
Echo of the login_hint, if any. Unverified for a direct client (front-channel passthrough). For a broker client it is the signature-verified hint from the request object, the same value bound as sub |
preferred_username |
Broker clients only. The signed hint, echoed back so account-matching works with default configuration. Not emitted for direct clients |
email, email_verified |
Broker clients only, and only when the signed hint is an email address. email_verified is always false, because LFA verifies location rather than mailboxes. Do not treat it as a verified address |
octet_location_assertion |
The signed Location Assertion (nested JWS) stating the region verdict |
Why brokers get identity claims and direct clients do not
A direct client's login_hint is an ordinary query parameter. Anyone can put any
value there, so reflecting it into email or preferred_username would let a
caller impersonate someone to their own RP. A broker's hint arrives inside a
signature-verified request object, signed by a key LFA has pinned, and LFA returns
it only to the party that signed it. That is not LFA vouching for an identity. It
is handing back the broker's own assertion, which is why email_verified stays
false.
GET /userinfo
Returns the same identity claims as the id_token: sub, octet_login_hint, and
(for brokers) preferred_username, email, email_verified. Some products build
their user profile from UserInfo rather than the id_token, so the two are kept
identical by design. Present the access token as a bearer token. An id_token
or location assertion is rejected with 401.
The Location Assertion
A compact ES256 JWS, verifiable against the same JWKS, asserting the region verdict for this login. It expires 120 seconds after issue and is bound to the login's nonce. It is an audit-grade statement of presence at that moment, not a credential. The subject is device-rooted (the enrolled device, not your user id), so the assertion can be verified downstream without carrying your user identifiers.
POST /v1/device/revoke
Revokes the device bound to one of your users. This is the lost or stolen phone switch. See Device lifecycle.
| Authentication | Client credentials, exactly as at /token (client_secret_post or Basic) |
| Body | octet_user_id=<the user whose device to revoke> |
| Effect | The user's bound device is denied from the next proof submission onward. Their next successful login on a new phone binds it automatically |
| Scope | Per client. You can only revoke bindings under your own client_id |
Errors
| Error | Where | Meaning |
|---|---|---|
invalid_request (PKCE, state, or nonce missing) |
/authorize |
The request does not meet the protocol requirements |
invalid_request (signed request object missing or invalid) |
/authorize |
A broker client sent no request= object, or one that failed signature, iss, aud, exp, or nbf verification, or used an unsupported algorithm |
invalid_request (request-object client_id mismatch) |
/authorize |
The client_id inside the signed object does not match the query client_id (RFC 9101 §5) |
invalid_request (login_hint missing from request object) |
/authorize |
A broker's signed object carried no login_hint, so there is no user to bind |
access_denied |
callback redirect | The phone's proof failed verification or policy: wrong region, stale proof, spoof verdict below the floor, attestation failure, or revoked device |
invalid_grant (code expired or already redeemed) |
/token |
Codes are single-use and live 60 seconds |
invalid_request (octet_user_id is required) |
/token |
Production exchanges from a direct client must carry the user identifier |
invalid_grant (this device is bound to a different user) |
/token |
The proving phone belongs to another account |
invalid_grant (a new device requires enrollment) |
/token |
The user already has a bound device. See Device lifecycle |
401 |
/userinfo |
The bearer token is not an access token (token_use mismatch) |