Skip to content

Use case — Device Code (RFC 8628) ​

The device flow lets a device with no usable keyboard or browser (a TV, a CLI, a printer) get tokens by sending the user to a second device to approve. For the concepts, including when to pick it and why slow_down and expired_token exist, read the Device Code primer first. This page covers the wiring.

A code on one screen, typed into another
An input-constrained device asks the OP for a device code and a short user code, shows the code, and polls the token endpoint. The user reads the code off that screen, enters it on their own phone or laptop, and approves there; the next poll returns tokens.DeviceTV · CLI · printerOPgo-oidc-providerUser's devicephone · laptopPOST /device_authorization200 OKdevice_code · user_code · URLshow the user_codeand the URLa person reads the code and types it elsewhereenter the code and approvePOST /token · polltokens, once approved
The dashed path is a person, not a network. Everything the flow needs from that leg is one short code, chosen to be readable and typeable rather than unguessable — the device_code the device keeps is the secret.
device_code vs user_code — what's the difference?

Two different identifiers come back from /device_authorization. device_code is a long opaque string the device keeps to itself and submits on every /token poll — it's effectively a bearer credential for "this pending authorization". user_code is the short, human-typeable string ("BDWP-HQPK") the device shows on its screen so the user can enter it on their phone or laptop. They live for the same duration (expires_in) but are presented to entirely different audiences; the user never sees device_code, the OP never accepts user_code on /token.

verification_uri vs verification_uri_complete — what's the difference?

verification_uri is the bare URL the user visits and types user_code into manually — printed on the screen for users who can't scan. verification_uri_complete is the same URL with user_code pre-filled as a query parameter, ideal for QR codes so the user doesn't have to type anything. Both reach the same embedder-owned page; the page should pre-populate from the user_code query parameter when it is present, and fall back to a manual input form otherwise.

interval and polling — what's that?

RFC 8628 has the device call /token repeatedly until the user approves on their phone. interval (seconds) is the minimum delay between polls that the OP asks for. If the device polls faster, the OP answers slow_down and raises the stored interval, so every replica enforces the new minimum. The default is 5 seconds.

Enabling the grant ​

go
import (
  "time"

  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/grant"
  "github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)

st := inmem.New() // ships a DeviceCodeStore substore

provider, err := op.New(
  op.WithIssuer("https://op.example.com"),
  op.WithStore(st),
  // The grant resolves end users, and there is no PrimaryPassword to infer
  // the user store from, so name it.
  op.WithUserStore(st.Users()),
  op.WithKeyset(myKeyset),
  op.WithCookieKeys(myCookieKey),

  // The input device has no browser, so naming grant.DeviceCode here drops the
  // default {authorization_code, refresh_token} pair (which would need a LoginFlow).
  op.WithGrants(grant.DeviceCode),

  // WithDeviceCodeGrant mounts /device_authorization and requires the
  // DeviceCodes substore.
  op.WithDeviceCodeGrant(op.DeviceCodeGrant{
    TTL:          10 * time.Minute, // optional; default 10 minutes
    PollInterval: 5 * time.Second,  // optional; default 5 seconds
  }),

  op.WithStaticClients(op.PublicClient{
    ID:           "tv-app",
    RedirectURIs: nil, // device-code clients never visit /auth
    GrantTypes:   []string{"urn:ietf:params:oauth:grant-type:device_code"},
    Scopes:       []string{"openid", "profile", "offline_access"},
  }),
)

op.WithDeviceCodeGrant(...) does four things:

  1. Adds grant.DeviceCode to the grant set, on top of whatever op.WithGrants lists (naming it in op.WithGrants as well is a no-op).
  2. Mounts /device_authorization at the configured endpoint path.
  3. Registers the device-code URN (urn:ietf:params:oauth:grant-type:device_code) at /token.
  4. Advertises device_authorization_endpoint and the URN in grant_types_supported in the discovery document.

The grant resolves an end user at approval, so op.New fails unless a user store is set: op.WithUserStore, or the Store of an op.PrimaryPassword in op.WithLoginFlow.

The device-code substore (store.DeviceCodeStore) is required. The in-memory, SQL, and DynamoDB adapters each ship one; the SQL adapter persists to the oidc_device_codes table on sqlite, mysql, and postgres. The Redis adapter returns nil for this substore, so on a Redis-based deployment route DeviceCodes to a durable tier (SQL or in-memory) through the composite adapter.

Substore presence is checked at op.New

If the configured store returns a nil DeviceCodes() substore, op.New fails with a configuration error instead of panicking on the first poll. The check applies whether you activate the grant with op.WithDeviceCodeGrant(...) or with op.WithGrants(grant.DeviceCode, ...).

The verification page ​

/device_authorization returns a verification_uri that points at the page where users approve the request. The library does not host this page, for two reasons:

  1. Branding and UX: the page belongs next to the rest of your sign-in UI.
  2. Anti-abuse policy: per-record brute-force limits, IP rate limiting, captcha, and audit triage belong with your existing fraud controls.

The default URI is <issuer>/device; set VerificationURI: "https://acme.com/connect" in op.DeviceCodeGrant if your verification page lives elsewhere (it must be an absolute URL).

expires_in and interval have their own fields, DeviceCodeGrant.TTL and DeviceCodeGrant.PollInterval, independent of op.WithAccessTokenTTL. A deployment with 30- or 60-second access tokens can still give the user ten minutes to reach a phone, enter the code, and approve.

Device authorization record: states and who moves it
A device authorization starts Pending. Your verification page moves it to Approved or Denied, the brute-force limit moves it to Denied, and a successful token poll moves Approved to Consumed. Revoke turns Pending or Approved into Denied, and a record that outlives expires_in answers expired_token.Pending/device_authorizationApprovedsubject and auth_time recordedConsumedtokens issued onceDeniedthe poll gets access_deniedApproveUserCode/token poll succeedsDenyUserCodeuser_code lockout · RevokeRevokeA record that outlives expires_in answers expired_token.
Your page owns the two transitions out of Pending. The OP owns the last one, and it happens at most once per record.

user_code is brute-forceable by design

A user_code has to be short enough to type, so an attacker can guess it unless the verification page limits attempts. The library ships op/devicecodekit so you do not have to build that limit yourself. For the first manual entry, use VerifyUserCodeByAttemptKey with an opaque server-side ceremony key, then call ApproveUserCode after authentication and consent. Never use the raw user_code as the attempt key.

Verifying a submitted user_code ​

go
import (
    "crypto/rand"
    "encoding/base64"
    "errors"
    "time"

    "github.com/libraz/go-oidc-provider/op/devicecodekit"
)

// Build deps once, retain it, and pass the same pointer to every helper.
// Deps contains a mutex-backed default limiter; never copy it after use.
deps := &devicecodekit.Deps{
    DeviceCodes:  st.DeviceCodes(),
    AuditLogger: auditLogger, // optional; receives helper audit records
}

// The attempt key identifies one server-side ceremony (for example the
// signed-in browser session on the verification page). Mint it once when
// the ceremony starts and reuse it for every submission in that ceremony;
// the limiter charges each call against it. A key minted per submission
// would give every guess a fresh budget. It is opaque and must not be the
// user-supplied user_code.
var rawAttemptKey [32]byte
if _, err := rand.Read(rawAttemptKey[:]); err != nil { /* handle 500 */ }
attemptKey := base64.RawURLEncoding.EncodeToString(rawAttemptKey[:])
// ... store attemptKey in the ceremony's server-side session ...

matched, err := devicecodekit.VerifyUserCodeByAttemptKey(ctx, deps, attemptKey, submittedUserCode)
switch {
case err == nil && matched:
    // Code matched — proceed to consent screen.
case errors.Is(err, devicecodekit.ErrAlreadyDecided):
    // The record was already approved or denied. Surface "already used".
case errors.Is(err, devicecodekit.ErrUnknownDeviceCode), errors.Is(err, devicecodekit.ErrAttemptLocked):
    // Unknown/malformed or over-budget entry. Do not reveal which.
case errors.Is(err, devicecodekit.ErrAttemptLimiterSaturated):
    // The built-in limiter is full of live keys: an overload signal, not a
    // verdict on this key. Ask the user to retry shortly.
default:
    // Unexpected — log and surface a generic error.
}

// After the authenticated user approves the requested scopes:
if matched {
    err = devicecodekit.ApproveUserCode(ctx, deps, submittedUserCode, approvedSubject, time.Now())
}

The helper:

  • Canonicalises the submitted string (case folding, hyphen stripping).
  • Compares in constant time against the stored value.
  • Charges the opaque attempt key before normalization and lookup, so malformed and unknown entries consume the same bounded budget. Each mismatch emits the device_code.verification.user_code_brute_force audit event through Deps.AuditLogger when one is configured.
  • Uses a built-in limiter that is process-local and bounded. A deployment with several OP instances must put a shared, atomic AttemptLimiter in Deps.AttemptLimiter; a separate read-then-increment implementation is not safe.
  • Leaves the record-bound helpers (VerifyUserCode, VerifyUserCodeByUserCode) on their own per-record limit of devicecodekit.MaxUserCodeStrikes (5), after which the record is denied with the reason user_code_lockout.

If the user pressed deny rather than mistyping, your handler calls devicecodekit.DenyUserCode(ctx, deps, submittedUserCode, devicecodekit.DenyReasonUserDenied), or devicecodekit.Revoke for a known device-code id. Either call emits its audit event through Deps.AuditLogger and does not charge a manual-entry mismatch.

After approval ​

Once the user has authenticated and consented, your handler calls ApproveUserCode with the same *devicecodekit.Deps pointer to move the record to Approved. It emits device_code.verification.approved after the store accepts the transition.

go
// `deps` is the retained pointer passed to VerifyUserCodeByAttemptKey.
// authTime is the wall-clock when the user actually authenticated; the
// token endpoint stamps id_token.auth_time from it (omit-on-zero), and
// clients with `RequireAuthTime` registered enforce it on the gate.
err := devicecodekit.ApproveUserCode(ctx, deps, submittedUserCode, approvedSubject, time.Now())

The next /token poll from the device succeeds.

What /device_authorization returns ​

sh
curl -s -d 'client_id=tv-app&scope=openid profile' \
  https://op.example.com/oidc/device_authorization
json
{
  "device_code": "f8b2c1d4...long-opaque",
  "user_code": "BDWP-HQPK",
  "verification_uri": "https://op.example.com/device",
  "verification_uri_complete": "https://op.example.com/device?user_code=BDWP-HQPK",
  "expires_in": 600,
  "interval": 5
}

The device shows the user_code and verification_uri. If it can render a QR code, encode verification_uri_complete so the user does not have to type the code at all.

The client must list urn:ietf:params:oauth:grant-type:device_code in its GrantTypes, or this endpoint answers 400 unauthorized_client. A scope outside the client's registered scopes gets 400 invalid_scope; omitting scope uses the client's full registered set.

RFC 8707 resource= ​

Devices may pin the issued access token to a specific resource server by sending resource=<absolute URI> on /device_authorization. The endpoint applies the same rules as /auth and /token:

  • The value MUST be an absolute URI (RFC 8707 §2). Relative URIs are rejected with 400 invalid_target.
  • The canonical form (lowercase scheme and host, trailing slash stripped) MUST appear in the client's registered Resources allowlist. A resource the client was never registered for is rejected with 400 invalid_target; Resources is the only source of aud values the OP will mint.
  • Multiple non-empty resource= values are rejected with 400 invalid_target, because a device authorization carries a single audience and the OP will not silently drop the others.

Add every resource server URI a device may request to the client's Resources (in the client seed or dynamic-registration metadata) before the device sends it.

Sender-constrained device records ​

When DPoP or mTLS is used, /device_authorization records the DPoP key thumbprint and/or the mTLS certificate thumbprint. At /token, the OP checks every binding on the record again before issuing a token. A record with both bindings needs both a matching DPoP proof and a matching mTLS certificate; one is not enough. A missing or mismatched proof fails redemption. On success, the token's cnf carries every binding the OP rechecked. Under a profile that mandates sender constraint (FAPI 2.0), /device_authorization itself rejects a request that carries neither.

Polling responses ​

Wire responseMeaning
400 authorization_pendingUser has not approved yet. Poll again after interval seconds.
400 slow_downPolled too fast. Add 5 seconds to the polling interval for this and later polls, per RFC 8628 §3.5. The OP stores the new interval, so every replica enforces it. If storing the poll observation fails, the OP emits device_code.poll_observation.failed and still answers the poll.
400 access_deniedThe user denied, the user_code limit locked the record, devicecodekit.Revoke was called, or the device kept polling faster than the interval until the poll-abuse limit denied the record. Stop polling.
400 expired_tokendevice_code outlived expires_in, is unknown, or was already redeemed. Stop polling.
400 invalid_grantThe device_code belongs to another client, or a DPoP / mTLS binding on the record is missing or does not match.
200 { access_token, ... }Approved. Treat it as a normal token response.

A successful poll returns an access_token, plus an id_token when the granted scope includes openid, plus a refresh_token when the client is registered for the refresh_token grant, refresh_token is in the grant set (the default set has it), and the scope includes openid (and offline_access too under op.WithStrictOfflineAccess). The redemption records a consent grant for that client and subject, so the tokens resolve their subject at introspection and /userinfo (the pairwise value when the client is pairwise), and a later /auth request for the same scopes can skip the consent screen.

Revoking a device when the user removes it ​

When the user removes a device ("remove this TV" in account settings), the tokens already issued to it should stop working at the same moment. devicecodekit.Revoke denies the authorization record and then revokes every credential issued under it.

Cascade revocation — what's that?

Revoking a parent record (here, the device authorization) should also revoke every credential issued from it. Without that, the TV keeps a working access token and refresh token after "remove this TV". The OP issues every credential from a device authorization under one grant identifier derived from the device code, and Revoke walks that identifier through each store you give it.

go
deps := &devicecodekit.Deps{
    DeviceCodes:        st.DeviceCodes(),
    AccessTokens:       st.AccessTokens(),       // JWT access-token registry / fallback
    GrantRevocations:   st.GrantRevocations(),   // grant tombstones (the default JWT strategy)
    OpaqueAccessTokens: st.OpaqueAccessTokens(), // opaque access tokens
    RefreshTokens:      st.RefreshTokens(),      // refresh-token chains
    Grants:             st.Grants(),             // deletes the consent record last
    AuditLogger:        auditLogger,
}

if err := devicecodekit.Revoke(ctx, deps, deviceCodeID, devicecodekit.DenyReasonUserRevokedDevice); err != nil {
    // The record is already denied; the cascade may be partial. Revoke is safe to repeat.
}

Wire each store that your deployment actually issues credentials into; a nil field skips that part of the cascade. Deps.RevocationStrategy must match op.WithAccessTokenRevocationStrategy; the default, grant tombstone, writes a tombstone through GrantRevocations (or falls back to AccessTokens when GrantRevocations is nil), and Deps.AccessTokenTTL should equal the provider's access-token TTL so the tombstone outlives the tokens it covers. With the JTI-registry strategy AccessTokens is required, and with the None strategy no JWT backend is needed. Revoke returns ErrMissingRevocationBackend when the selected strategy has nothing to write to.

Revoke attempts every store even when an earlier one fails, deletes the grant record only after all of them succeed, and returns a joined error otherwise. The device_code.revoked audit event carries cascade_complete and a count or flag per store it attempted.

See it run ​

examples/31-device-code-cli drives the full RFC 8628 round trip:

sh
(cd examples/31-device-code-cli && GOWORK=off go run -tags example .)

The example boots the OP, prints a boxed user_code panel + verification_uri_complete shortcut, simulates browser approval after a few seconds, and polls until the OP issues an access_token + id_token. Files are split by role (op.go / cli.go / device.go / probe.go).

  • Device Code primer — the flow explained from the device's point of view.
  • CIBA wiring — when the user is on a different surface but no code-on-screen ceremony fits.