Device Authorization with Return Confirmation (DARC)
Plain RFC 8628 has a well-known weakness: the channel between the device and
the user’s phone is unauthenticated. An attacker can start a device flow,
send the displayed user_code to a victim (“enter this code to join the
meeting”), and receive the victim’s tokens once the victim signs in and
approves on the genuine verification page. MFA does not help: the victim is
the one performing it. This is consent phishing, the dominant technique for
bypassing MFA with the device grant since 2020.
DARC closes it with one extra step. After the user approves, the verification page shows a short confirmation code. The user types it on the device that started the flow, and the device sends it with its token request. The attacker never sees that page, so the attacker’s device never gets tokens unless the victim reads the code back to them — which the page explicitly tells them never to do.
DARC is a profile of RFC 8628: the same endpoints and grant type, a few new parameters, errors and metadata values, and no new cryptography.
Policy
Ahdapa decides whether a flow needs the confirmation; the client only states whether it can accept a code.
[device_flow]
confirmation = "required" # or "off" for plain RFC 8628
confirmation_exempt_clients = [] # rollout aid: client ids that run plain RFC 8628
confirmation_code_length = 6 # 6–10
confirmation_charset = "numeric" # or "alphanumeric" (RFC 8628 base-20 set)
confirmation_code_ttl = 180 # seconds after display, capped by expires_in
confirmation_max_attempts = 3 # wrong codes before the session is denied
confirmation = "required" is the default. A client that cannot accept a
code, and is not exempt, gets confirmation_unavailable: the server never
silently continues without the confirmation.
Device authorization request
| Parameter | Meaning |
|---|---|
confirmation_input | numeric (digit keys), alphanumeric, or none. Absent means the client does not implement DARC. |
login_hint | The account the device expects. Hint lock: only that account may approve; another account ends the request. Accepted only from authenticated (non-public) clients. Starting a request never cancels other pending requests: anyone able to start one for an account could otherwise cancel that user’s sign-in. |
authorization_details | RFC 9396 details, shown on the consent page as asserted by the client. Only clients listing the type in authorization_details_types may send it. |
curl -s -X POST https://idp.example.com/device_authorization \
-u "kdc-host:SECRET" \
-d scope=openid \
-d confirmation_input=numeric \
--data-urlencode login_hint=alice@EXAMPLE.COM \
--data-urlencode 'authorization_details=[{"type":"krb5_tgt","realm":"EXAMPLE.COM","principal":"alice@EXAMPLE.COM","armor":"anonymous"}]'
{
"device_code": "…",
"user_code": "WDJB-MJHT",
"verification_uri": "https://idp.example.com/device",
"expires_in": 600,
"interval": 5,
"confirmation_required": true,
"confirmation_code_length": 6,
"confirmation_code_charset": "numeric"
}
Show the input field for the confirmation code from the start, next to the
user_code, so the user knows before visiting the page that a second step
happens on the device.
Verification page
The page follows RFC 8628 up to consent. The consent screen labels every statement by its source:
- Verified: signing in as alice@EXAMPLE.COM — the hint lock matched the signed-in user.
- Asserted by client name: — the
authorization_details. Known types are rendered as sentences, for examplekrb5_tgtas “Kerberos sign-in to EXAMPLE.COM as alice@EXAMPLE.COM from an unidentified terminal (anonymous armor)”.
After Allow, the page shows the code:
Almost done. Type this code on the device you are signing in to:
481 207
This code only works on the device in front of you.
Never read it out, paste it into another website, or send it to anyone —
no support agent, colleague or meeting host will ever ask for it.
[ I didn't start this — cancel and report ]
The code is generated at the moment of consent, stored only as a hash bound
to the device code, never put in a URL, and the device pages are served with
Cache-Control: no-store and Content-Security-Policy: frame-ancestors 'none'. The web UI (/ui/auth/device) shows the same step.
Session states
stateDiagram-v2
[*] --> PENDING : device authorization
PENDING --> APPROVED : user signed in and allowed (code shown)
PENDING --> DENIED : deny, or another account on a hinted request
APPROVED --> ISSUED : token request with the correct code
APPROVED --> DENIED : attempts exhausted / cancel and report
PENDING --> EXPIRED : expires_in elapsed
APPROVED --> EXPIRED : code lifetime elapsed
ISSUED --> [*]
DENIED --> [*]
EXPIRED --> [*]
There is exactly one transition to ISSUED, and it needs both the consent
and the code arriving from the device.
Token endpoint
The device polls as in RFC 8628 and adds confirmation_code once the user
has typed it. A code sent before approval is answered with
authorization_pending and uses no attempt.
| Condition | Response |
|---|---|
| User has not approved yet | authorization_pending |
| Polling too fast | slow_down |
| Approved, no code submitted | confirmation_required (keep polling, or prompt the user) |
| Approved, wrong code, attempts left | invalid_confirmation |
| Wrong code with no attempts left; denied; cancelled and reported; another account tried a hinted request | access_denied |
| Request or code lifetime elapsed | expired_token |
| Device code already redeemed | invalid_grant |
| Approved, correct code | 200 with tokens |
The ID token carries the approving session’s auth_time, acr and amr,
so a client can map the strength of the user’s sign-in (for example to
Kerberos authentication indicators).
Metadata and audit
Discovery advertises the capability, not the per-client policy:
"device_authorization_confirmation_supported": true,
"device_authorization_confirmation_charsets_supported": ["numeric", "alphanumeric"]
Audit events: device.approved, device.denied, device.reported,
device.confirmation-failed, device.confirmation-exhausted and
device.approved-unconfirmed. The last one — approval followed by no
confirmation within the code lifetime — is the signature of an asynchronous
consent-phishing lure.
What DARC does not stop
A victim coached in real time (“I’m from IT, read me the code”) can still hand the code over. DARC turns a mass email campaign into a live conversation per victim, contradicts the pretext on the page, and records the signals above; restricting high-value access to identified requesters (for example host-armored Kerberos requests) closes the rest.
The federation demo (contrib/demo/federation/run.sh, Step 10) walks through
every case above with a KDC-like client and a user signing in through an
upstream IdP.