> ## Documentation Index
> Fetch the complete documentation index at: https://docs.privy.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Authorized wallet access for self-hosted agents

**This document describes how to let any self-hosted agent or CLI securely access user wallets in a Privy app without distributing app secrets or requiring a browser during transactions.**

The flow uses the [OAuth 2.0 Device Authorization Grant](https://oauth.net/2/device-flow/), the same pattern GitHub CLI and other developer tools use for headless authentication. Users approve agent access once via a browser; the agent stores tokens and transacts autonomously within that authorization.

## When to use agent authorization

Use agent authorization when you want to allow your users to use their wallets via a self-hosted agent (such as Claude Code, Codex, OpenClaw, or a custom CLI). Agent authorization is the foundation for building agent-first experiences on top of your Privy app. By implementing this flow, your app's will be able to deliver rich experiences via any of agentic interfaces, including CLIs, MCP servers, Skills, connectors, and autonomous agents.

Agent authorization is a good fit when:

* The agent runs in a CLI or headless environment with no persistent browser
* Wallet operations need to happen autonomously after a one-time user approval
* App secrets must not be distributed to end-user machines
* The app does not want to require a backend server to proxy wallet requests to the Privy API, including the authentication and authorization that entails

## How it works

The flow has three participants: the **agent** (CLI or autonomous process), the **user** (approves via browser), and **your app** (hosts the verification page and holds the Privy app).

<Steps>
  <Step title="Agent requests a device code">
    The agent calls Privy's device authorization endpoint and receives a `device_code` (kept secret on the machine) and a `user_code` (short, human-readable). The agent displays the verification URL and user code:

    ```
    Visit:  https://your-app.com/authorize?user_code=ABCD-1234
    Enter:  ABCD-1234

    Waiting for authorization…
    ```
  </Step>

  <Step title="User approves in browser">
    The user opens the verification URL (a page your app hosts), signs in with Privy, and approves the
    agent's request. The verification page calls Privy's `device_verify` endpoint with the user code
    and the user's access token.
  </Step>

  <Step title="Agent receives tokens">
    The agent polls Privy's token endpoint at a fixed interval. Once the user approves, the response
    contains an access token and a refresh token. The agent stores these tokens for subsequent
    requests.

    <Warning>
      Store tokens in a secure credential store — for example, the OS keychain on macOS or an
      encrypted secrets store on Linux. Never write tokens to a plaintext file on disk.
    </Warning>
  </Step>

  <Step title="Agent transacts">
    For each wallet operation, the agent exchanges the access token for an ephemeral signing key, signs the request, and calls Privy's wallet RPC endpoint directly. No app secret required.
  </Step>
</Steps>

## Enable in dashboard

Navigate to your app in the [Privy Dashboard](https://dashboard.privy.io), open **Authentication** -> **Advanced** and toggle **Enable for CLI and agent access** on. Set the **Verification URI**, the URL where users will approve agent requests.

The verification URI must point to a page your app hosts. Privy returns this URI to the agent in the device authorization response so the agent can display it to the user.

<Tip>
  The verification URI is typically a dedicated route in your web app, for example
  `https://your-app.com/authorize`.
</Tip>

## Build the verification page

The verification page is the only browser-side step in the flow. It reads the `user_code` from the query string, prompts the user to log in if needed, and calls `device_verify` to approve or deny the agent's request.

Privy's device authorization endpoint returns a `verification_uri_complete` that pre-fills the user code as a query parameter, so users arriving via that link will not need to type a code manually.

<Tabs>
  <Tab title="React">
    ```tsx theme={"system"}
    import {useSearchParams} from 'react-router-dom';
    import {usePrivy} from '@privy-io/react-auth';

    const PRIVY_APP_ID = process.env.NEXT_PUBLIC_PRIVY_APP_ID!;

    export function AgentAuthorizationPage() {
      const {getAccessToken, authenticated, login} = usePrivy();
      const [searchParams] = useSearchParams();
      const userCode = searchParams.get('user_code') ?? '';

      async function submit(action: 'approve' | 'deny') {
        if (!authenticated) {
          await login();
          return;
        }

        const userAccessToken = await getAccessToken();
        const res = await fetch('https://auth.privy.io/api/oauth/v2/device_verify', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'privy-app-id': PRIVY_APP_ID,
            Authorization: `Bearer ${userAccessToken}`,
          },
          body: JSON.stringify({user_code: userCode, action}),
        });

        if (!res.ok) throw new Error(`device_verify failed: ${res.status}`);
      }

      if (!userCode) {
        return <p>No code found. Open the link provided by the agent.</p>;
      }

      return (
        <div>
          <p>
            Code: <strong>{userCode}</strong>
          </p>
          {!authenticated ? (
            <button type="button" onClick={() => login()}>
              Log in to continue
            </button>
          ) : (
            <>
              <button type="button" onClick={() => submit('approve')}>
                Approve
              </button>
              <button type="button" onClick={() => submit('deny')}>
                Deny
              </button>
            </>
          )}
        </div>
      );
    }
    ```
  </Tab>

  <Tab title="Vanilla JS">
    ```javascript theme={"system"}
    async function verifyDeviceAuth({appId, userAccessToken, userCode, action}) {
      const response = await fetch('https://auth.privy.io/api/oauth/v2/device_verify', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'privy-app-id': appId,
          Authorization: `Bearer ${userAccessToken}`,
        },
        body: JSON.stringify({user_code: userCode, action}),
      });

      if (!response.ok) throw new Error(`device_verify HTTP ${response.status}`);
      return response.json();
    }
    ```
  </Tab>
</Tabs>

<Warning>
  The `device_verify` endpoint returns `400` for invalid or expired user codes. If the agent reports
  `expired_token` or `access_denied` during polling, instruct the user to restart the login flow.
</Warning>

## Integrate device authorization in your agent

The following covers the full API flow for a CLI or agent. All requests go to `https://auth.privy.io` and require the `privy-app-id` header. For the complete request and response schemas, see the [OpenAPI specification](https://auth.privy.io/api/v1/openapi.json).

### Request a device code

Call this once at the start of login to receive the codes the agent will use for polling and display.

```shell theme={"system"}
POST https://auth.privy.io/api/oauth/v2/device_authorization
privy-app-id: <your-app-id>
Content-Type: application/json

{}
```

```json theme={"system"}
{
  "device_code": "a3f8c2e1d4b7...",
  "user_code": "ABCD-1234",
  "verification_uri": "https://your-app.com/authorize",
  "verification_uri_complete": "https://your-app.com/authorize?user_code=ABCD-1234",
  "expires_in": 600,
  "interval": 5
}
```

Display `verification_uri_complete` (or `verification_uri` + `user_code` separately) to the user. Keep `device_code` secret on the machine; it is used only for polling. The device code expires after 10 minutes.

**Error:** `403 device_auth_not_enabled`: device authorization is not enabled for this app in the dashboard.

### Poll for an access token

Poll this endpoint at the `interval` from the previous response (in seconds) until the user approves.

```shell theme={"system"}
POST https://auth.privy.io/api/oauth/v2/token
privy-app-id: <your-app-id>
Content-Type: application/json

{
  "grant_type": "device_code",
  "device_code": "a3f8c2e1d4b7..."
}
```

```json theme={"system"}
{
  "access_token": "eyJhbGci...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "d9e3f1a2b4c6..."
}
```

While polling, the endpoint returns `400` with one of the following errors:

| Error                   | Action                                                |
| ----------------------- | ----------------------------------------------------- |
| `authorization_pending` | User has not approved yet; keep polling               |
| `slow_down`             | Polling too fast; increase interval by 5 seconds      |
| `expired_token`         | Device code expired; restart from the previous step   |
| `access_denied`         | User denied access; stop polling and surface an error |

### Refresh an access token

Exchange a refresh token for a new access token when the current one is near expiry or after receiving a `401` from a wallet endpoint. The old refresh token is immediately invalidated.

```shell theme={"system"}
POST https://auth.privy.io/api/oauth/v2/token
privy-app-id: <your-app-id>
Content-Type: application/json

{
  "grant_type": "refresh_token",
  "refresh_token": "d9e3f1a2b4c6..."
}
```

```json theme={"system"}
{
  "access_token": "eyJhbGci...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "e8f2a3b5c7d1..."
}
```

Store the new `refresh_token`. If this endpoint returns `access_denied`, the refresh token has expired or the user revoked access, and the user must re-authorize from the beginning.

### Get a wallet signing key

Before submitting wallet operations, exchange the access token for an ephemeral signing key. Provide an HPKE public key so the response is encrypted to the agent process only.

```shell theme={"system"}
POST https://auth.privy.io/api/oauth/v2/wallets/authenticate
Authorization: Bearer <access_token>
privy-app-id: <your-app-id>
privy-grant-type: device_code
Content-Type: application/json

{
  "encryption_type": "HPKE",
  "recipient_public_key": "<base64-encoded SPKI public key>"
}
```

```json theme={"system"}
{
  "encrypted_authorization_key": {
    "encapsulated_key": "<base64-encoded encapsulated key>",
    "ciphertext": "<base64-encoded ciphertext>"
  },
  "expires_at": "2024-01-01T00:15:00.000Z",
  "wallets": [{"id": "wallet_abc123", "address": "0x...", "chain_type": "ethereum"}]
}
```

Decrypt `encrypted_authorization_key` with the corresponding HPKE private key. Cache and reuse the key until `expires_at`. It produces the `privy-authorization-signature` header on RPC requests.

<Warning>
  Keep the decrypted signing key in memory only. Never write it to disk or log it — it grants direct
  signing authority over the user's wallet for the duration of its lifetime.
</Warning>

### Submit a wallet RPC

Use the Privy wallet ID (for example, `wallet_abc123`), not the on-chain address.

```shell theme={"system"}
POST https://auth.privy.io/api/oauth/v2/wallets/{wallet_id}/rpc
Authorization: Bearer <access_token>
privy-app-id: <your-app-id>
privy-grant-type: device_code
privy-authorization-signature: <signature>
Content-Type: application/json
```

<CodeGroup>
  ```json Send a transaction theme={"system"}
  {
    "method": "eth_sendTransaction",
    "params": {
      "transaction": {
        "to": "0xdeadbeef...",
        "value": "0x5af3107a4000",
        "chain_id": 8453
      }
    }
  }
  ```

  ```json Sign a message theme={"system"}
  {
    "method": "personal_sign",
    "params": {
      "message": "Hello from the agent"
    }
  }
  ```
</CodeGroup>

```json theme={"system"}
{
  "method": "eth_sendTransaction",
  "data": {
    "hash": "0x7f8e9d..."
  }
}
```

| Error                       | Action                                           |
| --------------------------- | ------------------------------------------------ |
| `401 access token expired`  | Refresh via the token endpoint, then retry       |
| `403 wallet not accessible` | Wallet does not belong to the authenticated user |

## Token lifetimes

| Artifact                    | Lifetime                      |
| --------------------------- | ----------------------------- |
| `device_code` / `user_code` | 10 minutes                    |
| Access token                | 15 minutes                    |
| Refresh token               | 30 days (rotated on each use) |

## Managing authorizations

Users can list and revoke active agent authorizations at any time. These endpoints require a valid user access token.

### List active authorizations

```shell theme={"system"}
GET https://auth.privy.io/api/oauth/v2/grants
Authorization: Bearer <user_access_token>
privy-app-id: <your-app-id>
```

```json theme={"system"}
{
  "data": [
    {
      "id": "grant_abc123",
      "grant_type": "device_code",
      "created_at": 1717000000,
      "last_used_at": 1717003600
    }
  ]
}
```

### Revoke an authorization

```shell theme={"system"}
DELETE https://auth.privy.io/api/oauth/v2/grants/{grant_id}
Authorization: Bearer <user_access_token>
privy-app-id: <your-app-id>
```

```json theme={"system"}
{"success": true}
```

Revoking a grant immediately invalidates all refresh tokens associated with it. The corresponding access token (JWT) remains valid until its natural expiry of up to 15 minutes. Surface the list and revoke endpoints in any user-facing account settings so users can audit and terminate agent access.

## Learn more

<CardGroup cols={2}>
  <Card title="Agent CLI" icon="terminal" href="/recipes/agent-integrations/agent-cli" arrow>
    Give any agent a wallet with a CLI command. No integration code needed.
  </Card>

  <Card title="Agentic wallets" icon="wallet" href="/recipes/agent-integrations/agentic-wallets" arrow>
    Create developer-controlled agent wallets with policy guardrails.
  </Card>

  <Card title="Policies" icon="shield" href="/controls/policies/overview" arrow>
    Constrain agent behavior with transfer limits, allowlists, and time-based controls.
  </Card>

  <Card title="x402 payments" icon="money-bill" href="/recipes/agent-integrations/x402" arrow>
    Enable HTTP-native payments for APIs and digital content.
  </Card>
</CardGroup>
