Device Authorization Grant with Auth0: The Native Client Gotcha
The Device Authorization Flow is Auth0's implementation of RFC 8628, and it solves a problem none of the other flows in my Auth0 session and token demo suite can touch: how does a device with no browser and often no keyboard authenticate a user at all? A smart TV, a CLI tool, a set-top box — none of them can redirect anywhere. The device displays a short code, the user opens a link on their phone or laptop, types the code (or scans a QR that pre-fills it), logs in normally, and the device polls in the background until it has a token.
My demo suite also has a CIBA demo, Auth0's other "decoupled" flow, and it is worth being precise about how the two differ. CIBA already knows who the user is — the requesting app targets a specific identity via login_hint and pushes an approval to a device that user already owns. Device Authorization Grant knows nothing about the user at all until the second screen resolves it, and it needs no MFA enrolment, no Enterprise plan, and no prior session on the initiating side. Building it turned up one thing the documentation does not spell out clearly: the client type you use for every other flow in Auth0 will not work here at all. In this post I will detail the actual request and response shapes, the client type requirement that broke my first attempt twice, and a QR code lesson I got backwards building the CIBA demo in the same suite.
The steps we will cover in this post are as follows.
- What the flow actually asks Auth0 for
- The client type Auth0 insists on
- What you lose (and gain) by going public
- The QR code lesson I already got wrong once
What the flow actually asks Auth0 for
The device makes one call to start things off:
curl -X POST https://{yourDomain}/oauth/device/code \
-d client_id={yourClientId} \
-d scope="openid profile email" \
-d audience={yourAudience}Auth0 returns everything the device needs to display and poll on:
{
"device_code": "ajJNp_HqEAnMKBkgfR0vPA46",
"user_code": "CNMH-DWTK",
"verification_uri": "https://{yourDomain}/activate",
"verification_uri_complete": "https://{yourDomain}/activate?user_code=CNMH-DWTK",
"expires_in": 900,
"interval": 5
}From there the device polls /oauth/token with grant_type=urn:ietf:params:oauth:grant-type:device_code every interval seconds. Before the user approves anything, Auth0 comes back with authorization_pending on a 403 — RFC 8628 itself specifies 400 for this exact error, so build your polling loop around the error field, not the status code. Once the user finishes logging in at verification_uri_complete, the very next poll returns a completely normal token response — access token, ID token, refresh token if you asked for offline_access, all decoded and displayed the same way every other demo in this suite shows them.
The client type Auth0 insists on
Every confidential client in this demo suite — Traditional, BFF, On-Behalf-Of, Custom Token Exchange — is a regular_web application with a client secret held server-side. I built the Device Authorization Grant application the same way out of habit, added urn:ietf:params:oauth:grant-type:device_code to its grant_types, and Auth0 rejected it outright:
{
"statusCode": 400,
"error": "Bad Request",
"message": "Application Type must be \"native\" when urn:ietf:params:oauth:grant-type:device_code is enabled"
}Fair enough — I switched app_type to native and tried again. Auth0 rejected it a second time, for a different reason:
{
"statusCode": 400,
"error": "Bad Request",
"message": "Application must be OIDC Conformant when urn:ietf:params:oauth:grant-type:device_code is enabled"
}Adding oidc_conformant: true alongside app_type: "native" finally got a 201. Neither of these constraints is unreasonable once you think about it — a device with no secure secret storage should not be issued a client secret to protect — but neither of them is called out anywhere obvious in the flow documentation either. I would rather have found this in a docs page than by reading the error message twice.
What you lose (and gain) by going public
The consequence of app_type: "native" is that Auth0 sets token_endpoint_auth_method: "none" on the client. There is no client secret anywhere in this flow, at any point — not in the device's poll request, not stored server-side, not rotated, not leaked. It is the only demo in the whole suite where I did not have to think about secret handling at all, which is a nice change of pace after building CIBA and Custom Token Exchange back to back, both of which live and die by keeping a client_secret off the browser.
The trade-off is the one you would expect from any public client: anyone who knows the client_id can request a device code and drive the same flow. That is fine for this grant specifically, because the thing being protected is the user's login, not the client's identity — the security boundary is entirely at verification_uri_complete, where the user is expected to authenticate for real.
The QR code lesson I already got wrong once
Building the CIBA demo in the same suite, I generated my own QR code from Auth0's Guardian enrolment ticket URL. It looked reasonable and it was completely wrong — Guardian's own in-app scanner expects a different payload format entirely, and the ticket URL is meant to be opened as a link, not decoded by a QR reader inside the app itself.
Device Authorization Grant is close to the mirror image of that mistake, and it is worth stating plainly so I do not second-guess myself the same way twice: verification_uri_complete genuinely is designed by the RFC to be QR-encoded. A phone's ordinary camera app scanning it just opens a normal web page with the code pre-filled. There is no special scanner involved, no app-specific payload format, nothing to get backwards. I still render a plain link alongside the QR in the demo, mostly so a presenter testing on the same device they are demoing from does not need a second phone in reach.
The one genuine risk worth naming here is device-code phishing, which is a real, documented attack against this exact grant: an attacker requests their own device code, then relays the user_code or link to a victim under some pretext — "verify your account", "IT needs this to finish setup" — and the victim authenticates for real, unknowingly authorising the attacker's session instead of their own. Short-lived codes and Auth0's own rate limiting on code entry help, but the underlying defence is the same as any phishing risk: the verification page is the entire trust boundary, and a user who is not looking closely at what they are approving is the attack surface.
Worth being precise about what does and does not catch this: Auth0's adaptive and risk-based policies evaluate whatever device completes login at verification_uri_complete — in a phishing scenario, that's the victim's own legitimate device, on their own network. The risk engine never sees the attacker's device or network at all; it only ever sees a plausible, familiar login from the victim's side. Don't assume a risk policy tuned for this tenant automatically covers device-code phishing just because it catches credential stuffing — it's evaluating the wrong half of the transaction to do that.
Conclusion
Device Authorization Grant ends up being the simplest client to provision in this entire demo suite, once you know it wants a native, OIDC-conformant, secret-free application rather than the confidential type everything else uses. The live demo walks through requesting a code, approving it on a second screen, and polling for the result — worth comparing directly against the CIBA demo in the same suite to see the two decoupled flows side by side.