ErtisAuth

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).

  1. 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.
  2. The user opens your website or app on their phone or computer, where they are already signed in, and enters the user code.
  3. Your site shows which device is asking and lets the user approve or deny it.
  4. The device, which has been polling with its device code in the meantime, receives a token pair for that user.
flow
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.

json
{
	"_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"
}
FieldRequiredDescription
nameyesDisplay name.
slugnoDerived from the name when omitted. The membership's code_policy refers to it.
lengthyesNumber of characters of every user code, from 5 to 12, for every character set.
contains_letters, contains_digitsat least oneThe character set: letters, digits, or both. Codes with both leave out the characters that are easily confused (0, O, 1, I).
expires_inyesHow 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}.

MethodRoutePermission
GET/code-policies/{id}code-policies.read.{id}
GET/code-policiescode-policies.read
POST/code-policies/_querycode-policies.read
POST/code-policiescode-policies.create
PUT/code-policies/{id}code-policies.update.{id}
DELETE/code-policies/{id}code-policies.delete.{id}
DELETE/code-policiescode-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#

  1. Create a policy:
    shell
    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 }'
  2. Set "code_policy": "tv-codes" on the membership.

Token codes#

All routes are under /memberships/{membershipId}.

MethodRouteDescriptionWho calls itPermission
POST/codesGenerate a codethe device (or its backend)tokens.create
GET/codes/{user_code}Show the device of a codeyour approval pagetokens.create, Bearer token
POST/codes/{user_code}/approveApprove a codeyour approval pagetokens.create, Bearer token
POST/codes/{user_code}/denyDeny a codeyour approval pagetokens.create, Bearer token
POST/codes/tokenGet the token with the device codethe devicenone

1. Generate a code#

http
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

json
{
	"_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"
}
FieldUse
user_codeShow 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_codeKeep 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.
intervalSeconds to wait between two token requests.
expire_timeAfter this time the code can't be used; request a new one.
ErrorWhen
404 TokenCodePolicyNotFoundThe membership has no code_policy, or the policy does not exist
503 TokenCodeCouldNotBeGeneratedNo 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:

http
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?
ErrorWhen
404 TokenCodeNotFoundUnknown or expired code
400 TokenTypeNotSupportedThe 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#

http
POST /memberships/{membershipId}/codes/K7Q2XD9M/approve
Authorization: Bearer <user_access_token>
http
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.

ErrorWhen
400 TokenTypeNotSupportedThe token is not a Bearer token
401 TokenCodeExpiredThe code has expired
404 TokenCodeNotFoundUnknown code
409 TokenCodeAlreadyAuthorizedThe 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:

http
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.

ResponseMeaningWhat the device does
201 Created with a token pairApprovedStores the tokens and stops polling
401 UnauthorizedTokenCodeNot approved yetWaits interval seconds and polls again
400 TokenCodeSlowDownPolled before interval seconds passedWaits longer before the next poll
401 TokenCodeDeniedThe user denied the requestStops; shows a message, offers a new code
401 TokenCodeExpiredThe code expiredRequests a new code
401 UserInactiveThe approving user has been deactivated sinceRequests a new code
401 InvalidTokenUnknown or already used device codeRequests 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#

javascript
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.create permission.
  • Rate-limit POST /codes and POST /codes/token at your gateway.

Found a mistake in the docs? Open an issue