Device Code Flow
Sign in on devices without a keyboard.
The device code flow signs users in on devices where typing a password is impractical: smart TVs, set-top boxes, kiosks, game consoles, command-line tools. It follows the model of the OAuth 2.0 device authorization grant (RFC 8628).
- The device asks ErtisAuth for a code. It gets two values: a short user code to show on screen, and a long device code that it keeps to itself.
- The user opens your website or app on their phone or computer, where they are already signed in, and enters the user code.
- Your site shows which device is asking and lets the user approve or deny it.
- The device, which has been polling with its device code in the meantime, receives a token pair for that user.
Device ErtisAuth Your site (user signed in)
│ POST /codes │ │
│─────────────────────────────────────────▶ │ │
│ { user_code: "K7Q2XD9M", │ │
│ device_code: "…", interval: 5 } │ │
│◀───────────────────────────────────────── │ │
│ shows "K7Q2-XD9M" │ user types K7Q2XD9M │
│ │ GET /codes/K7Q2XD9M (device info) │
│ │ ◀──────────────────────────────────────── │
│ │ POST /codes/K7Q2XD9M/approve │
│ │ ◀──────────────────────────────────────── │
│ POST /codes/token { device_code } │ │
│ (every `interval` seconds) │ │
│─────────────────────────────────────────▶ │ │
│ 201 { access_token, … } │ │
│◀───────────────────────────────────────── │ │Why two codes? The user code is visible to anyone who can see the screen, so it only identifies the request. The token can only be obtained with the device code, which never leaves the device.
Code policies#
The format of the user codes is set by a code policy, and each membership names the policy it uses in its code_policy field.
{
"_id": "66f1c0d2a4b5c6d7e8f90160",
"name": "TV Codes",
"slug": "tv-codes",
"description": "8 characters, easy to read on a TV",
"length": 8,
"contains_letters": true,
"contains_digits": true,
"expires_in": 300,
"membership_id": "66f1c0d2a4b5c6d7e8f90123"
}| Field | Required | Description |
|---|---|---|
name | yes | Display name. |
slug | no | Derived from the name when omitted. The membership's code_policy refers to it. |
length | yes | Number of characters of every user code, from 5 to 12, for every character set. |
contains_letters, contains_digits | at least one | The character set: letters, digits, or both. Codes with both leave out the characters that are easily confused (0, O, 1, I). |
expires_in | yes | How long a code can be approved and used, in seconds, from 1 to 1800 (30 minutes). |
A policy outside these limits is rejected with 400 ModelValidationError when it is created or updated.
Choose the length with the screen in mind: on a TV, 5 to 8 characters are comfortable to read and type. Since the user code alone can't give a token, a short code is safe; a longer one mostly makes it harder to guess a code of someone else that is waiting for approval.
Endpoints#
All routes are under /memberships/{membershipId}.
| Method | Route | Permission |
|---|---|---|
GET | /code-policies/{id} | code-policies.read.{id} |
GET | /code-policies | code-policies.read |
POST | /code-policies/_query | code-policies.read |
POST | /code-policies | code-policies.create |
PUT | /code-policies/{id} | code-policies.update.{id} |
DELETE | /code-policies/{id} | code-policies.delete.{id} |
DELETE | /code-policies | code-policies.delete |
A policy used by the membership can't be deleted (409 TokenCodePolicyInUse). A duplicate slug answers 409 TokenCodePolicyAlreadyExists, an update without changes 409 IdenticalDocumentError.
Enable the flow#
- Create a policy:
curl -X POST https://auth.example.com/memberships/<membership_id>/code-policies \ -H 'Authorization: Bearer <access_token>' \ -H 'Content-Type: application/json' \ -d '{ "name": "TV Codes", "length": 8, "contains_letters": true, "contains_digits": true, "expires_in": 300 }' - Set
"code_policy": "tv-codes"on the membership.
Token codes#
All routes are under /memberships/{membershipId}.
| Method | Route | Description | Who calls it | Permission |
|---|---|---|---|---|
POST | /codes | Generate a code | the device (or its backend) | tokens.create |
GET | /codes/{user_code} | Show the device of a code | your approval page | tokens.create, Bearer token |
POST | /codes/{user_code}/approve | Approve a code | your approval page | tokens.create, Bearer token |
POST | /codes/{user_code}/deny | Deny a code | your approval page | tokens.create, Bearer token |
POST | /codes/token | Get the token with the device code | the device | none |
1. Generate a code#
POST /memberships/{membershipId}/codes
Authorization: Basic <application_id>:<secret>
X-IpAddress: 203.0.113.42
X-UserAgent: LivingRoomTV/2.4 (Tizen 7.0)The device calls this through your backend, or with the credentials of an application dedicated to devices whose role has only tokens.create. Credentials embedded in a device can be extracted, so give them no other permission.
X-IpAddress and X-UserAgent describe the device; when they are missing, the address and user agent of the request are used. They are shown to the user before the approval and stored with the session.
Response 201 Created
{
"_id": "66f1c0d2a4b5c6d7e8f90170",
"user_code": "K7Q2XD9M",
"device_code": "Qm9xZ1pWc2F0aE5vV2xvUjNjZ2dXbFFmZ3NtWm9Kdw",
"status": "pending",
"expires_in": 300,
"interval": 5,
"created_at": "2026-01-01T12:00:00Z",
"expire_time": "2026-01-01T12:05:00Z",
"client_info": { "ip_address": "203.0.113.42", "user_agent": "LivingRoomTV/2.4 (Tizen 7.0)" },
"membership_id": "66f1c0d2a4b5c6d7e8f90123"
}| Field | Use |
|---|---|
user_code | Show it on the screen, together with the address of your approval page. You may split it for readability (K7Q2-XD9M): case, dashes and spaces are ignored when the user enters it. |
device_code | Keep it in the device's memory and use it to get the token. It is returned only in this response; ErtisAuth stores only its hash. Never show or log it. |
interval | Seconds to wait between two token requests. |
expire_time | After this time the code can't be used; request a new one. |
| Error | When |
|---|---|
404 TokenCodePolicyNotFound | The membership has no code_policy, or the policy does not exist |
503 TokenCodeCouldNotBeGenerated | No unused user code was found; try again (very unlikely, unless the policy has very few possible codes) |
2. Show the device to the user#
On your approval page the signed-in user enters the code. Before approving, show them what they are about to sign in:
GET /memberships/{membershipId}/codes/K7Q2XD9M
Authorization: Bearer <user_access_token>Response 200 OK: the code without its device code, with status (pending, approved or denied), created_at and client_info:
Sign in on LivingRoomTV/2.4 (Tizen 7.0) from 203.0.113.42, requested 1 minute ago?
| Error | When |
|---|---|
404 TokenCodeNotFound | Unknown or expired code |
400 TokenTypeNotSupported | The token is not a Bearer token |
This step protects users from code phishing: an attacker generates a code on their own device and tricks the victim into entering it ("enter this code to claim your prize"). If the user sees an unknown device, they deny the request.
3. Approve or deny#
POST /memberships/{membershipId}/codes/K7Q2XD9M/approve
Authorization: Bearer <user_access_token>POST /memberships/{membershipId}/codes/K7Q2XD9M/deny
Authorization: Bearer <user_access_token>The token must be a user's Bearer token: the device will be signed in as this user. An application's Basic token is rejected with 400 TokenTypeNotSupported. The user's role needs tokens.create.
Response 200 OK with the code, now approved or denied, and the id of the user in user_id. A TokenCodeApproved or TokenCodeDenied event is recorded.
A code can be approved or denied only once, and only while it is pending: if two people try at the same time, only one succeeds.
Approving with a scoped token. When the approval is made with a scoped token (its scopes must cover tokens.create), the device gets a token limited to the same scopes: a device can never get more than the session that approved it. Such a token lives for the membership's scoped_token_expires_in (12 hours by default), and keeps its scopes when it is refreshed. An approval with an ordinary access token gives the device an ordinary token.
| Error | When |
|---|---|
400 TokenTypeNotSupported | The token is not a Bearer token |
401 TokenCodeExpired | The code has expired |
404 TokenCodeNotFound | Unknown code |
409 TokenCodeAlreadyAuthorized | The code was already approved or denied |
4. Get the token#
The device polls every interval seconds until it gets a token or the code expires:
POST /memberships/{membershipId}/codes/token
Content-Type: application/json
{ "device_code": "Qm9xZ1pWc2F0aE5vV2xvUjNjZ2dXbFFmZ3NtWm9Kdw" }No token is needed. The device code is sent in the body, not in the URL, so that it doesn't end up in access logs.
| Response | Meaning | What the device does |
|---|---|---|
201 Created with a token pair | Approved | Stores the tokens and stops polling |
401 UnauthorizedTokenCode | Not approved yet | Waits interval seconds and polls again |
400 TokenCodeSlowDown | Polled before interval seconds passed | Waits longer before the next poll |
401 TokenCodeDenied | The user denied the request | Stops; shows a message, offers a new code |
401 TokenCodeExpired | The code expired | Requests a new code |
401 UserInactive | The approving user has been deactivated since | Requests a new code |
401 InvalidToken | Unknown or already used device code | Requests a new code |
- The token pair is generated when the device gets it, so its lifetime starts then, and it is handed out once: the code is deleted at that moment.
- The session records the device's IP address and user agent, so it is easy to recognize in the user's sessions.
- From then on the device refreshes its token like any other client.
Example device loop#
const code = await post(`/memberships/${membershipId}/codes`); // through your backend
showOnScreen(code.user_code.replace(/(.{4})/, "$1-"), "https://example.com/tv");
let interval = code.interval;
while (Date.now() < Date.parse(code.expire_time)) {
await sleep(interval * 1000);
const response = await post(`/memberships/${membershipId}/codes/token`, { device_code: code.device_code });
if (response.status === 201) return signIn(response.body);
const { errorCode } = response.body;
if (errorCode === "TokenCodeSlowDown") interval += 5;
else if (errorCode !== "UnauthorizedTokenCode") break; // denied, expired, …
}
offerANewCode();Security notes#
- The user code is not a secret; the device code is. Keep the device code in memory only, and use HTTPS.
- Always show the device information before the approval, and let users deny unknown devices.
- Give the credentials used by devices only the
tokens.createpermission. - Rate-limit
POST /codesandPOST /codes/tokenat your gateway.
Found a mistake in the docs? Open an issue