This guide registers a mobile or desktop client on a fapi2 tenant and walks the calls your app makes to the edge native endpoints. At the end you hold a DPoP-bound access token, have called an API with it, refreshed it and revoked it. You need an edge with a native block and a dpop block, and an auth with a mutual-TLS listener. The concepts are on Native apps through edge.
The example uses https://edge.example.com for edge, https://id.example.com for auth, acme-mobile as the client ID and https://app.example.com/callback as the redirect URI.
Prerequisites
- A tenant on the
fapi2security profile, linked to your edge. The default tenant isfapi2. edgeconfigured as in Edge configuration:nativeblock,dpopblock,EDGE_NATIVE_BLOB_KEY.authwith amutual-tlsblock whosetrusted-certificatescontains the CAcentralsigns client certificates with, andcentralwith aclient-certificatesbackend (Edge client certificates). The CLI targets configure these.- A domain you control for the App Link (Android) or Universal Link (iOS), with the association file hosted, so that
https://app.example.com/callbackopens your app. - A resource behind
edgeand a role that grants it, as in How to protect an API with edge, if you want to call an API at the end.
1. Create the client
- Open Clients and start the create wizard.
- In step 1, choose Mobile or desktop app. The plan panel lists what the wizard fixes for this kind on your tenant: PKCE
S256, mTLS byedge, DPoP-bound access tokens, pushed authorization requests, App Link redirects only, a 1 hour access token, refresh token rotation. - In Basics, enter the client ID
acme-mobile, a name and the redirect URIhttps://app.example.com/callback. A custom scheme such ascom.example.app://is refused on afapi2tenant. - Give the client the scopes the app needs, in the wizard or later on the client’s edit page:
openid, andoffline_accessif the app needs a refresh token. - Review and create.
The credential is edge-provisioned: there is nothing to enter or store. In Central API terms the wizard registers applicationType: native, authMethod: tls_client_auth, dpopBoundAccessTokens: true, requirePushedAuthorizationRequests: true, certificateBoundAccessTokens: false and enrollEdgeClientCertificate: true. If you register by API, send those fields, since enrollEdgeClientCertificate and issueEdgeClientCertificate are required and never defaulted.
To narrow the DPoP proof algorithms for this client, open the client and set the DPoP algorithms. Leave it empty to accept what the server advertises (ES256 and PS256 in the default metadata).
If the app will call an API, add acme-mobile to the Audience of the resource and make sure the users have a role with the permission.
2. Let edge enroll the certificate
Nothing to do. Within configuration-cache-refresh-interval (generated: 5 minutes), edge loads the client, generates a key pair and asks central to sign a certificate for it (enrollment steps).
To check that edge serves the client, call start without a proof:
curl -i -X POST https://edge.example.com/native/start/acme-mobile
HTTP/1.1 400 Bad Request
{"error":"invalid_dpop_proof","error_description":"request must carry a DPoP proof from the device key"}
A 400 means edge has a certificate and treats the client as native. A 404 means it does not yet, or the client does not meet the requirements. Wait one interval and read the edge log for edge_certificate_unavailable.
3. Prepare the device key
On the device, generate an ES256 key pair in the keystore once and keep it for the life of the installation. For each call you build a proof, a JWT signed with the private key:
| Part | Content |
|---|---|
| Header | typ: dpop+jwt, alg: ES256, jwk: the public key |
| Payload | htm: POST, htu: see the table below, iat: now, jti: a fresh random value, nonce when edge or auth asked for one |
Any DPoP-capable library builds this (RFC 9449). The htu differs per call:
| Call | htu |
|---|---|
| start | https://edge.example.com/native/start/acme-mobile |
| complete, token | token_endpoint from the start response |
| revoke | revocation_endpoint from the start response |
4. Walk the calls
The DPoP values below are placeholders for the proofs from step 3.
Start. Push the authorization request through edge.
curl -X POST https://edge.example.com/native/start/acme-mobile \
-H "DPoP: <proof for start>" \
-d "scope=openid offline_access"
{"client_id":"acme-mobile","request_uri":"urn:ietf:params:oauth:request_uri:...","expires_in":600,
"authorization_endpoint":"https://id.example.com/authorize","state":"...","blob":"...",
"token_endpoint":"https://id.example.com:8083/token","revocation_endpoint":"https://id.example.com:8083/revoke"}
If the answer is 401 with DPoP-Nonce, build the proof again with that nonce and retry.
Browser. Open the system browser at authorization_endpoint with client_id and request_uri:
https://id.example.com/authorize?client_id=acme-mobile&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A...
The user signs in. auth redirects to https://app.example.com/callback?code=...&state=...&iss=..., which opens your app.
Complete. Within expires_in seconds, send the three values and the blob, with a proof for token_endpoint.
curl -X POST https://edge.example.com/native/complete/acme-mobile \
-H "DPoP: <proof for token_endpoint>" \
-d "code=<code>" -d "state=<state>" -d "iss=<iss>" -d "blob=<blob>"
{"access_token":"...","token_type":"DPoP","expires_in":3600,"refresh_token":"...","scope":"openid offline_access"}
Call the API. The proof for an API call is addressed to the edge URL and carries htm of the request method and the hash of the access token (ath).
curl https://edge.example.com/resources/orders-api/orders \
-H "Authorization: DPoP <access token>" \
-H "DPoP: <proof for this request>"
Refresh.
curl -X POST https://edge.example.com/native/token/acme-mobile \
-H "DPoP: <proof for token_endpoint>" \
-H "Idempotency-Key: <unique per rotation>" \
-d "refresh_token=<refresh token>"
Revoke on sign-out.
curl -X POST https://edge.example.com/native/revoke/acme-mobile \
-H "DPoP: <proof for revocation_endpoint>" \
-d "token=<refresh token>" -d "token_type_hint=refresh_token"
Refreshing the same refresh token again now returns invalid_grant.
5. When it fails
| Answer | Likely cause |
|---|---|
404 from any /native/* call | No native block. The client is not native, or has no edge certificate yet. The client’s tenant is not linked to this edge. The client ID is wrong |
400 invalid_dpop_proof | Missing or second DPoP header. Wrong htu (use the URLs from the start response, not your own). iat outside the allowed window. The proof from start was reused, so build a new one for each call. The key algorithm is not accepted |
401 with DPoP-Nonce | A nonce is required. Retry once with a proof that includes it |
400 invalid_grant on complete | The blob expired (expires_in passed). state or iss does not match what the redirect returned. The proof was signed with a different key than the one that called start. If iss is the cause, check that native.auth-issuer equals auth’s jwt.issuer |
400 invalid_request | redirect_uri is not registered, a parameter is missing or repeated, or the body is not form-encoded |
invalid_client relayed from auth | auth does not trust the CA that signed edge’s certificate, or the certificate subject does not match mtlsAuth. See Edge client certificates |
400 relayed from /par about the redirect URI or PKCE | The client registration and the request disagree. Check the redirect URI and that the tenant is fapi2 |
Authorization at auth fails right after the browser opens | The request_uri expired or was used already. Call start again |
401 from the API with invalid_dpop_proof | The API proof lacks ath, uses the wrong htu (the edge URL of that call), or the token was sent as Bearer. A key-bound token is never accepted as Bearer |
Next steps
- Native apps through edge: the model behind these calls.
- Edge client certificates: renewal, files and failure modes.
- Sender-constrained tokens: DPoP and nonces.