A native app cannot keep a client secret, and the fapi2 security profile refuses public clients. edge solves this by splitting the client role. edge authenticates to auth as the client, with a certificate on the mutual-TLS listener. The device holds only a DPoP key, and that key is the sender constraint on the tokens. This page describes the four endpoints the app calls. You need edge configured with a native block (Edge configuration) and a native client (How to build a native mobile app flow).
The sequence
sequenceDiagram
autonumber
participant M as Mobile app
participant E as edge
participant A as auth
participant U as Upstream
M->>E: POST /native/start/{clientId} + DPoP proof (device key)
E->>A: POST /par as the client (PKCE, state, dpop_jkt)
E-->>M: request_uri, sealed blob
note over M,A: System browser opens auth /authorize, user signs in
A-->>M: Redirect to the App Link with code and state
M->>E: POST /native/complete/{clientId} + blob + DPoP proof
E->>A: POST /token (code + PKCE verifier from blob)
A-->>E: DPoP-bound access_token + refresh_token
E-->>M: Token response relayed unchanged
M->>M: Store tokens and device key (Keychain / Keystore)
note over M,E: Session active — app sends DPoP-bound calls
M->>E: GET /resources/api/v1/data + Authorization: DPoP + DPoP proof
E->>E: Check revocation, token binding, permissions and CEL rules
E->>U: Forward + inject Authorization header
U-->>E: 200 OK
E-->>M: 200 OK
note over M,E: Refresh
M->>E: POST /native/token/{clientId} (refresh_token) + DPoP proof
E->>A: POST /token as the client
A-->>E: new tokens
E-->>M: new tokens
| Step | Who | Call |
|---|---|---|
| 1. Start | App to edge | POST /native/start/{clientId} |
| 2. Sign in | System browser to auth | GET /authorize?client_id=...&request_uri=... |
| 3. Return | auth to the app | Redirect to the App Link with code, state and iss |
| 4. Complete | App to edge | POST /native/complete/{clientId} |
| 5. Use | App to edge | /resources/{resourceId}/{path} with Authorization: DPoP |
| 6. Refresh | App to edge | POST /native/token/{clientId} |
| 7. Sign out | App to edge | POST /native/revoke/{clientId} |
edge keeps no record of a login in progress. What it would store travels as a sealed blob (AES-256-GCM, key native.blob-key) that holds the client ID, the PKCE verifier, state, the thumbprint of the device key, the redirect URI and an expiry. Tokens never rest at edge: auth remains the only token store.
What the device holds
- A DPoP key pair, created once and kept in the platform keystore (Keychain, Android Keystore). Make it non-exportable. The algorithm must be one the server accepts:
ES256andPS256are in the default metadata, and the client can narrow the set withdpopSigningAlgs. PreferES256. - Tokens: the access token (
token_typeisDPoP) and the refresh token. The access token carriescnf.jkt, the thumbprint of the device key, and no certificate thumbprint. Without the key it is useless to a thief. - The blob only between step 1 and step 4.
Every call to /native/* carries one DPoP header: a JWT signed by the device key with htm (POST), htu, iat and a unique jti. The htu differs per call:
| Call | htu |
|---|---|
/native/start/{clientId} | The URL of that endpoint on edge, built from edge-url |
/native/complete/{clientId}, /native/token/{clientId} | token_endpoint from the start response |
/native/revoke/{clientId} | revocation_endpoint from the start response |
The last two are auth endpoints on its mutual-TLS address, and edge forwards the proof to them byte for byte. The proof for start is addressed to edge itself, is never forwarded and can be used once.
When a nonce is required, the answer is 401 with WWW-Authenticate: DPoP error="use_dpop_nonce" and a DPoP-Nonce header. Build a new proof that includes the nonce and retry once. A nonce for a proof addressed to auth comes from auth, and edge relays it unchanged.
Client requirements
edge serves a client on /native/* only if all of these hold. Otherwise it answers 404, and a missing client and a misconfigured one look the same.
| Requirement | Why |
|---|---|
applicationType is native | Marks an app fronted for the device |
authMethod is tls_client_auth, with an edge certificate (Client certificates) | edge authenticates as the client on auth’s mutual-TLS listener |
dpopBoundAccessTokens is true | The device key binds the tokens |
certificateBoundAccessTokens is false | edge’s certificate is shared by every installation, so it cannot identify one device |
requirePushedAuthorizationRequests is true | edge pushes the request to /par, so parameters never travel through the browser |
Redirect URIs are https App Links | A custom scheme can be claimed by any app. A fapi2 tenant refuses them |
The client’s tenant is assigned to this edge | central syncs only the clients of the tenant’s edge |
PKCE with S256 is always on. Signed request objects do not apply, because a shipped binary has no private key of its own. The console wizard Mobile or desktop app sets all of this for you.
Calls
All four calls take application/x-www-form-urlencoded bodies and answer with Cache-Control: no-store.
Start
curl -X POST https://edge.example.com/native/start/acme-mobile \
-H "DPoP: <proof for start>" \
-d "scope=openid offline_access" \
-d "redirect_uri=https://app.example.com/callback"
Accepted body parameters: redirect_uri (optional when the client registered exactly one), scope, nonce, acr_values, prompt, login_hint, ui_locales, max_age, claims, resource (may repeat) and authorization_details. Anything else is ignored, and a repeated parameter other than resource is invalid_request. edge adds response_type=code, the PKCE challenge, state and dpop_jkt, and pushes the request to auth.
{
"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"
}
expires_in is the shorter of the lifetime auth gave the request_uri and native.blob-ttl (default 600 s). Open the system browser on authorization_endpoint with client_id and request_uri as query parameters. Do not use an embedded web view.
Complete
When the browser returns to your App Link, read code, state and iss from it.
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>"
edge opens the blob and checks that it is its own, for this client and not expired, that state matches, that iss is the authorization server the request was pushed to (RFC 9207), and that the proof is signed by the same key that started the flow. It then redeems the code at auth with the PKCE verifier from the blob and relays auth’s token response unchanged. An Idempotency-Key header is forwarded to auth.
Call the API
Use the access token with Authorization: DPoP <access token> and a proof for the edge URL. The route and policy rules are in Endpoint policies.
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>"
Only the refresh_token grant is served. grant_type is optional, and any other value is unsupported_grant_type. scope and resource are forwarded. The Idempotency-Key makes a rotation safely retryable after a lost response.
Revoke
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"
This is RFC 7009 revocation, relayed from auth. A revoked refresh token answers invalid_grant on the next refresh.
Errors
| Status | Body | When |
|---|---|---|
400 | {"error": "invalid_request", "error_description": "..."} | Missing or repeated parameter, a redirect_uri that is not registered, body not form-encoded |
400 | invalid_dpop_proof | No proof, more than one, a failed check, or a start proof already used |
400 | invalid_grant | The blob is not from this edge, is for another client or has expired. state or iss does not match. The proof’s key differs from the one that started the flow |
400 | unsupported_grant_type | /native/token with a grant other than refresh_token |
401 | none, WWW-Authenticate: DPoP error="use_dpop_nonce", DPoP-Nonce | A nonce is needed. Retry once |
404 | none | Unknown client, a client that does not meet the requirements, or no native block |
The error object follows RFC 6749 section 5.2. Refusals from auth, such as a bad redirect_uri at /par or an invalid_grant for a used code, are relayed as auth sent them.
Configuration
The native block and its keys are on Edge configuration. It requires a dpop block, or edge does not start. On the auth side you need a mutual-tls listener whose trusted-certificates file contains the CA that signs the certificate edge presents.
Limits
/native/tokenserves only refresh. Other grants are not available throughedge.edgedoes not store tokens, so it cannot list or revoke a device’s tokens for you. Use the refresh-token and session views of a user in the console, orauth’s revocation.- A fapi2 tenant cannot use custom-scheme redirects. Plan for App Links (Android) or Universal Links (iOS) and host the association file on your domain.
Next steps
- How to build a native mobile app flow: register the client and run these calls.
- Edge client certificates: the certificate
edgeuses towardauth. - Sender-constrained tokens: DPoP in detail.