Device Flow Host Guide

Copy Markdown

Lockspire's Phase 31 device verification slice is a generated, host-owned /verify seam. Lockspire owns the durable lookup and approval state transitions; your Phoenix app owns the browser route, session/auth pipeline, layout, copy, and abuse controls around that seam.

Lockspire does not provide built-in rate limiting for GET /verify or POST /verify. Treat the generated controller or LiveView as a starting point that must be wrapped in host-owned anti-phishing, trusted-IP, and throttling rules before you ship it.

Polling and token redemption contract

Lockspire now ships the OAuth 2.0 device authorization and token polling endpoints as embedded library routes:

  • POST /device/code issues device_code, user_code, verification_uri, and a base interval of 5 seconds.
  • POST /token accepts grant_type=urn:ietf:params:oauth:grant-type:device_code and enforces the durable poll timing stored by Lockspire.

The happy path is:

  1. The device client requests a device authorization from POST /device/code.
  2. The user completes the host-owned /verify seam and explicitly approves the request.
  3. The device client polls POST /token no faster than every 5 seconds until Lockspire returns tokens.

Polling rules:

  • The base poll interval is 5 seconds.
  • A compliant poll before host approval returns authorization_pending.
  • If the client polls too early, Lockspire returns slow_down; the client must back off before polling again.
  • After approval, the next compliant poll redeems the device authorization once and returns the normal token response shape.

Terminal polling rules:

  • Stop polling on access_denied.
  • Stop polling on expired_token.
  • Stop polling on invalid_grant, including replay after a successful redemption.
  • Do not treat slow_down as terminal; increase the delay and continue polling.

Host-owned verification seam

The supported shape is:

  • GET /verify renders a code-entry page with optional prefill.
  • POST /verify performs a lookup on the submitted code and shows a review step.
  • Approve and deny are separate explicit mutations on an opaque verification handle, not on the raw code again.

Keep the seam narrow:

  • Let Lockspire decide whether a request is pending, expired, or no longer active.
  • Let the host app decide who is signed in, what session rules apply, and what product framing surrounds the page.
  • Keep GET /verify side-effect free.

Anti-phishing rules for verification_uri_complete

verification_uri_complete is prefill-only. In plain terms: verification_uri_complete is prefill-only. Use it to pre-populate the host-owned form and nothing more.

Required rules:

  • verification_uri_complete is prefill-only and must never auto-submit, auto-look-up, auto-approve, or auto-advance the flow.
  • Re-display the code on the review screen and ask the user to confirm it matches the code shown on the requesting device. The review step must re-display the code even when the form was prefilled from the query string.
  • Require an explicit user action before any lookup or approval.
  • Never approve on GET and never treat opening verification_uri_complete as proof of possession. In short: never approve on GET.
  • Do not log raw verification query strings because they can contain a user-entered code.

Rate limiting /verify

Apply rate limits to both GET /verify and POST /verify. The goal is to slow spray attacks and brute force without creating a code-existence oracle.

Recommended dimensions:

  • Primary IP bucket: throttle all requests by trusted client IP for both GET /verify and POST /verify.
  • Secondary normalized_user_code bucket: throttle repeated lookups against the same normalized code across IPs.
  • Tighter {normalized_user_code, ip} failure bucket: increment only when a POST /verify lookup fails or the user submits a mismatched review step.
  • Optional signed-in session or account bucket: once the user is authenticated, add a softer per-session or per-account limit to catch scripted retries behind shared networks.

Recommended behavior:

  • Return HTTP 429 with a short Retry-After.
  • Keep the user-facing copy neutral, for example "Try again in a moment."
  • Preserve the same invalid-or-expired posture whether the code is unknown or expired.
  • Increase POST /verify delay with stepped or exponential backoff on repeated failures in the same {normalized_user_code, ip} bucket.

Suggested starting points:

  • GET /verify: enough headroom for normal refreshes, but still bounded by IP.
  • POST /verify: stricter than GET because it is the brute-force surface.
  • {normalized_user_code, ip} failures: the tightest bucket in the system.

Tune exact numbers to your traffic profile, but keep the relative strictness: IP broadest, normalized_user_code narrower, failure bucket tightest.

Trusted IP and proxy handling

Rate-limit keys are only useful if the IP is trustworthy. Derive them from a trusted IP source.

  • If Phoenix sits behind a reverse proxy or load balancer, rewrite conn.remote_ip using a trusted proxy configuration before you derive limiter keys.
  • Trust only headers added by infrastructure you control.
  • Do not key limits directly from arbitrary x-forwarded-for values.
  • Make the IP extraction rule consistent across GET /verify, POST /verify, and any approve or deny endpoints.

If you already use RemoteIp or equivalent proxy-aware middleware, apply it before the verification seam.

Normalize the code before keying limits

Use the same normalization rule everywhere before deriving limit keys: strip separators and whitespace + uppercase.

That should match Lockspire's durable lookup rule for user_code canonicalization:

  • Input: wdjb-mjht
  • normalized_user_code: WDJBMJHT

Never key rate limits from the raw query string or raw form value when cosmetic separators can vary.

429 and audit guidance

When you throttle:

  • Send a short Retry-After header.
  • Avoid telling the user whether the code exists.
  • Keep logs and audit events keyed by fingerprints instead of raw codes.
  • Redact query strings and form payloads that contain device verification codes.

A simple pattern is to hash normalized_user_code again for logs and store only the fingerprint plus the IP/session dimensions that tripped the limiter.

Small custom limiter example

This Hammer-style example keeps the contract explicit without making Hammer a Lockspire dependency:

defmodule MyAppWeb.VerifyRateLimit do
  import Plug.Conn

  @get_window_ms :timer.minutes(1)
  @post_window_ms :timer.minutes(5)

  def allow_lookup!(conn, user_code) do
    normalized_user_code =
      user_code
      |> String.replace(~r/[^[:alnum:]]/u, "")
      |> String.upcase()

    ip = format_ip(conn.remote_ip)

    with :ok <- hit("verify:get:#{ip}", 30, @get_window_ms),
         :ok <- hit("verify:post:#{ip}", 10, @post_window_ms),
         :ok <- hit("verify:code:#{normalized_user_code}", 5, @post_window_ms) do
      {:ok, normalized_user_code}
    else
      {:error, retry_after_seconds} -> {:error, retry_after_seconds}
    end
  end

  def register_failure(normalized_user_code, ip) do
    hit("verify:failure:#{normalized_user_code}:#{ip}", 3, @post_window_ms)
  end

  defp hit(bucket, limit, window_ms) do
    case Hammer.check_rate(bucket, window_ms, limit) do
      {:allow, _count} -> :ok
      {:deny, retry_after_ms} -> {:error, max(1, div(retry_after_ms, 1_000))}
    end
  end

  defp format_ip(ip), do: :inet.ntoa(ip) |> to_string()
end

Pair the limiter with neutral 429 responses and a review step that still re-displays the code before approve or deny.

Router-layer example

This PlugAttack-style example blocks obvious spray traffic before the request reaches the host verification controller:

defmodule MyAppWeb.VerifyAttack do
  use PlugAttack

  rule "throttle GET /verify by ip", conn do
    if conn.method == "GET" and conn.request_path == "/verify" do
      throttle("verify:get:#{format_ip(conn.remote_ip)}", period: 60_000, limit: 30)
    end
  end

  rule "throttle POST /verify by ip", conn do
    if conn.method == "POST" and conn.request_path == "/verify" do
      throttle("verify:post:#{format_ip(conn.remote_ip)}", period: 300_000, limit: 10)
    end
  end

  defp format_ip(ip), do: :inet.ntoa(ip) |> to_string()
end

Use app-level code in the controller or LiveView for the tighter normalized_user_code and {normalized_user_code, ip} buckets, because those depend on the submitted code after normalization.

Ship checklist

Before you expose /verify publicly:

  • Confirm verification_uri_complete is prefill-only.
  • Confirm the review step re-displays the code and requires explicit confirmation.
  • Confirm GET /verify has no approval semantics.
  • Confirm trusted proxy IP rewriting runs before limiter keys are derived.
  • Confirm normalized_user_code keys strip separators and whitespace + uppercase.
  • Confirm 429 responses include Retry-After and neutral copy.
  • Confirm logs and audit trails use fingerprints instead of raw codes.
  • Confirm device clients are documented to start with a 5-second poll interval and to honor slow_down backoff.