# TV Sign-In with the Device Flow

**Summary:** Let members sign in to your TV app from their phone using the Subrite OIDC device flow. Request and display a code, poll for tokens, refresh access and sign out.

- Role: [Developers](https://www.subrite.no/developers)
- Source: https://www.subrite.no/developers/subrite-device-flow
- Updated: 2026-10-20
- Markdown index: https://www.subrite.no/developers/llms.txt

Typing an email address and a password with a TV remote is slow and error-prone. The device flow ([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)) moves the typing to the member's phone:

1. The TV asks Subrite for a code, then shows a QR code, a short URL and the code.
2. The member points their phone camera at the QR code, or opens the URL and types the code.
3. On the phone, the member signs in if they aren't already, sees your app's name and the code, and approves.
4. The TV polls Subrite every few seconds while this happens. The first poll after the member approves returns the tokens.

The TV gets the same tokens as the [web flow](https://www.subrite.no/developers/oauth-integration?md=true): same issuer, same API audience, same `sub`. A token from the TV works everywhere a web token does.

<a id="before-you-start"></a>
## Before You Start

<a id="get-the-device-flow-enabled-for-your-client"></a>
### Get the Device Flow Enabled for Your Client

The device flow is enabled per OIDC client. **For now, ask your Subrite contact to enable it.** We will enable it on stage first so you can test, and then on production.

A TV app is set up as a **public client**: it sends its `client_id` and no `client_secret`. Anyone can extract a secret from an app installed on a TV, so a secret would prove nothing ([RFC 8628 §5.6](https://www.rfc-editor.org/rfc/rfc8628#section-5.6)). A public client that sends a `client_secret` anyway is refused with `invalid_client`.

If your platform can only authenticate with a secret, tell us, and we will keep your client confidential. Then send `client_id` and `client_secret` on every request on this page, the same way as in the web flow.

<a id="endpoints-and-discovery"></a>
### Endpoints and Discovery

Every tenant has its own issuer on its own domain. The examples on this page use `https://example.com`; replace it with your tenant's domain. Request the code, poll for tokens, refresh and revoke on the same domain: a code from one tenant does not work on another.

Read the endpoints from the tenant's discovery document instead of hardcoding them:

```text
GET https://example.com/.well-known/openid-configuration
```

```json
{
  "issuer": "https://example.com/api/oidc",
  "device_authorization_endpoint": "https://example.com/api/oidc/device/auth",
  "token_endpoint": "https://example.com/api/oidc/token",
  "revocation_endpoint": "https://example.com/api/oidc/token/revocation"
}
```

The response is shortened here. `device_authorization_endpoint` and `revocation_endpoint` are only present once the device flow is enabled for a client on the tenant. If they are missing, the device flow isn't enabled yet.

<a id="1-request-a-code"></a>
## 1. Request a Code

<a id="endpoint"></a>
### Endpoint

```text
POST /api/oidc/device/auth
```

<a id="headers"></a>
### Headers

```text
Content-Type: application/x-www-form-urlencoded
Accept: application/json
```

<a id="parameters"></a>
### Parameters

| Parameter | Required | Type | Description |
| --- | --- | --- | --- |
| **client\_id** | Yes | string | Your Client ID. |
| **scope** | Yes | string | `openid offline_access` |
| **device\_name** | No | string | A name for the device, for example `Living room TV`. At most 64 characters. |
| **device\_model** | No | string | The device's model, for example `Samsung QE55Q80`. At most 64 characters. |

> [!WARNING]
> **Request offline\_access**
>
> Without `offline_access`, the TV's tokens are tied to the member's browser session on the phone. They stop working when that session ends, for example when the member signs out on the phone.

The member sees `device_name` and `device_model` on the approve screen, marked as reported by the device. A value longer than 64 characters, or one containing control characters or invisible characters, is refused with `invalid_request`. Don't send the device's IP address or location: Subrite observes those itself. Any other parameter is ignored.

<a id="example-request"></a>
### Example Request

```bash
curl --request POST 'https://example.com/api/oidc/device/auth' \
  --header 'Accept: application/json' \
  --data-urlencode 'client_id=your-client-id' \
  --data-urlencode 'scope=openid offline_access' \
  --data-urlencode 'device_name=Living room TV' \
  --data-urlencode 'device_model=Samsung QE55Q80'
```

<a id="response"></a>
### Response

```json
{
  "device_code": "Sx8mJ0p2lW7bqC3vK9tYc1fN6uR4hZ5aE2dG8iL0oPq",
  "user_code": "482 913",
  "verification_uri": "https://example.com/activate",
  "verification_uri_complete": "https://example.com/activate?user_code=482%20913",
  "expires_in": 900,
  "interval": 5
}
```

| Field | Description |
| --- | --- |
| **device\_code** | The TV's secret for this sign-in. Send it when polling. Never show it, never put it in a URL and never log it. |
| **user\_code** | The code the member enters on the phone: 6 digits, shown as `482 913`. Show it exactly as you received it. |
| **verification\_uri** | The short URL where the member types the code. |
| **verification\_uri\_complete** | The same URL with the code included. This is what the QR code contains. |
| **expires\_in** | Seconds until the code expires: 15 minutes. After that, request a new code. |
| **interval** | Seconds to wait between polls of the token endpoint. |

<a id="errors"></a>
### Errors

| `error` | Meaning | What to do |
| --- | --- | --- |
| `invalid_client` | The `client_id` is unknown, or a public client sent a `client_secret`. | Check the client ID, and send no secret. |
| `invalid_request` | The device flow isn't enabled for your client, or a parameter is missing or invalid. `error_description` says which. | Ask us to enable the device flow, or fix the parameter. |
| `temporarily_unavailable` | Status `429`: too many code requests from the same network in the last minute. | Wait the seconds in the `Retry-After` header, then request a new code. |
| `server_error` | Something failed on our side. | Safe to retry: request a new code. |

If no client on the tenant has the device flow enabled, the endpoint does not exist and answers `404`.

<a id="2-show-the-code-on-the-tv"></a>
## 2. Show the Code on the TV

![The TV sign-in screen: a QR code on the left; the instruction, the short URL and the code on the right](https://cdn.sanity.io/images/1x2xswq6/production/8acd2a2092186f48fc1f2a1d424497e36e076761-1200x700.svg?w=1600&fit=max&auto=format)

- **Render** **`verification_uri_complete`** **as a QR code.** It is a plain `https://` URL; put it in the QR code exactly as you received it. Don't wrap it in a custom URL scheme, a link shortener or a redirect of your own.
- **Show** **`verification_uri`** **and** **`user_code`** **as text next to the QR code**, large enough to read from the sofa. Typing the code is a first-class path, not a fallback: not every member will scan.
- **Write "Point your phone camera at the code". Never "Scan with a QR app".** The phone's own camera opens the link directly. Third-party QR scanner apps have intercepted sign-in flows like this one with paywalls and look-alike pages.
- **Show the code exactly as** **`user_code`** **gives it.** The phone shows the same code on the approve screen, and the member compares the two before approving. That comparison is what stops a member from approving someone else's TV, so the code must stay on screen even when the member scans.
- When the code expires, request a new one and show it, or offer a button to do so.

<a id="3-poll-for-the-tokens"></a>
## 3. Poll for the Tokens

While the code is on screen, poll the token endpoint with the `device_code`.

<a id="endpoint-2"></a>
### Endpoint

```text
POST /api/oidc/token
```

<a id="headers-2"></a>
### Headers

```text
Content-Type: application/x-www-form-urlencoded
Accept: application/json
```

<a id="parameters-2"></a>
### Parameters

| Parameter | Required | Type | Description |
| --- | --- | --- | --- |
| **grant\_type** | Yes | string | `urn:ietf:params:oauth:grant-type:device_code` |
| **device\_code** | Yes | string | The `device_code` from step 1. |
| **client\_id** | Yes | string | Your Client ID. |

<a id="example-request-2"></a>
### Example Request

```bash
curl --request POST 'https://example.com/api/oidc/token' \
  --header 'Accept: application/json' \
  --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' \
  --data-urlencode 'device_code=Sx8mJ0p2lW7bqC3vK9tYc1fN6uR4hZ5aE2dG8iL0oPq' \
  --data-urlencode 'client_id=your-client-id'
```

<a id="the-polling-contract"></a>
### The Polling Contract

Wait `interval` seconds before the first poll and between polls. Send one poll at a time: wait for the answer before you start the next wait. Until the member has approved, the token endpoint answers `400` with one of these errors:

| `error` | Meaning | What to do |
| --- | --- | --- |
| `authorization_pending` | The member hasn't approved yet. | Keep polling. |
| `slow_down` | You polled before the interval was up. | Add 5 seconds to your interval, for this poll and all later ones, and keep polling. |
| `expired_token` | The code expired before the member approved. | Stop polling. Request a new code and show it. |
| `access_denied` | The member rejected the sign-in on the phone. | Stop polling. Tell the member the sign-in was declined, and let them start again. |
| `invalid_grant` | The device code is unknown, belongs to another client, or was already used. | Stop polling. Request a new code and show it. |

If an answer isn't JSON, or has status `429` or `5xx`, treat it as temporary: double your interval and keep polling until the code expires.

> [!CAUTION]
> **Stop polling once you have the tokens**
>
> A device code can be exchanged for tokens once. Polling again with the same device code after success is refused with `invalid_grant`, and it also ends the sign-in: the refresh token you just received stops working, and the TV is signed out when its access token expires.

<a id="response-2"></a>
### Response

The first poll after the member approves returns the tokens:

```json
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
  "expires_in": 14400,
  "id_token": "eyJhbGciOiJSUzI1NiJ9...",
  "refresh_token": "aR7kQ2nV9xL4mB8cT1wJ5yH3pF6sD0gE...",
  "scope": "openid offline_access",
  "token_type": "Bearer"
}
```

<a id="4-use-the-tokens"></a>
## 4. Use the Tokens

From here the TV is an ordinary OIDC client.

- **The access token** is a JWT for the Subrite API, valid for 4 hours. Send it as a Bearer token, as described in [Use Access Token to Make Requests to Subrite API](https://www.subrite.no/developers/oauth-integration?md=true#use-the-access-token-to-call-the-subrite-api). The member and subscription APIs are listed in [Member Information, Content, Consent, and Subscription Access APIs](https://www.subrite.no/developers/oauth-integration?md=true#member-information-and-access-apis).
- **`sub`** **is the member's numeric Subrite user id**, in both the access token and the ID token. It is the same in web and device tokens, and matches `userId` in the member profile response. It is not a UUID, and not the member's `id`.

<a id="refresh-the-access-token"></a>
### Refresh the Access Token

Refresh the same way as in the web flow, described in [Get Refresh Token](https://www.subrite.no/developers/oauth-integration?md=true#refresh-the-access-token): `POST /api/oidc/token` with `grant_type=refresh_token`, `refresh_token` and `client_id`. A public client sends no `client_secret`.

> [!CAUTION]
> **Refresh tokens rotate on every use**
>
> Every refresh response contains a **new** refresh token, and the one you sent stops working. Store the refresh token from every token response and discard the previous one.
>
> If an old refresh token is used again, Subrite treats it as stolen: it revokes the whole sign-in and answers `invalid_grant`. The TV is signed out, and the member has to sign in again.
>
> - Save the new refresh token before you do anything else with the response.
> - Never refresh twice in parallel with the same refresh token, for example from two threads or two app processes.

When a refresh answers `invalid_grant`, the sign-in has ended: it was revoked, it expired, or an old refresh token was reused. Delete the stored tokens and start the device flow again from step 1.

<a id="5-sign-out"></a>
## 5. Sign Out

To sign the TV out, revoke its refresh token. This ends the sign-in on Subrite, so the refresh token can't be used again.

<a id="endpoint-3"></a>
### Endpoint

```text
POST /api/oidc/token/revocation
```

<a id="headers-3"></a>
### Headers

```text
Content-Type: application/x-www-form-urlencoded
```

<a id="parameters-3"></a>
### Parameters

| Parameter | Required | Type | Description |
| --- | --- | --- | --- |
| **token** | Yes | string | The current refresh token. |
| **token\_type\_hint** | No | string | `refresh_token` |
| **client\_id** | Yes | string | Your Client ID. |

<a id="example-request-3"></a>
### Example Request

```bash
curl --request POST 'https://example.com/api/oidc/token/revocation' \
  --data-urlencode 'token=aR7kQ2nV9xL4mB8cT1wJ5yH3pF6sD0gE...' \
  --data-urlencode 'token_type_hint=refresh_token' \
  --data-urlencode 'client_id=your-client-id'
```

The endpoint answers `200` with an empty body, also when the token was already invalid.

Then delete all stored tokens on the TV. The access token is a JWT and stays valid until it expires (at most 4 hours), so the TV must stop using it itself.

The browser logout endpoint, `/api/oidc/session/end`, does not apply to a TV: there is no browser session to end.

<a id="checklist"></a>
## Checklist

☐ The device flow is enabled for your client (ask us), on stage before production.

☐ Endpoints come from `/.well-known/openid-configuration`.

☐ The code request includes `scope=openid offline_access`, and no `client_secret`.

☐ The TV shows the QR code, the URL and the code, and says "Point your phone camera at the code".

☐ Polling waits `interval` seconds, adds 5 seconds on `slow_down`, and stops on success or on any error other than `authorization_pending` and `slow_down`.

☐ Every new refresh token is stored, and the previous one is discarded.

☐ Signing out revokes the refresh token and deletes all tokens on the TV.
