Authentication
shithub's API authenticates calls with Personal Access Tokens. PATs are minted via the web UI or, for CLI / non-browser clients, through the device-code grant.
Header
Authorization: Bearer shp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Authorization: token shp_… is accepted as a synonym for tools
that hard-code GitHub's older syntax.
Token format
PATs are 40 characters of base32 with the shp_ prefix. They
match the regex:
shp_[A-Za-z0-9]{40}
Secret-scanning tools (GitHub's, GitGuardian's, etc.) recognize this prefix.
Failure modes
| Status | Body | Cause |
|---|---|---|
| 401 | {"error":"unauthenticated"} |
Missing or malformed header. |
| 401 | {"error":"invalid token"} |
Token doesn't exist, was revoked, or has expired. |
| 401 | {"error":"account suspended"} |
The owning account has been suspended by an admin. |
| 403 | {"error":"insufficient scope"} |
Token is valid but lacks the scope this route needs. |
Sessions
The web UI uses session cookies, not PATs. Session cookies are
not accepted on /api/v1/ — the API is PAT-only. This is a
deliberate choice: it keeps CSRF concerns off the API surface
and means every API caller is identified by an auditable token.
Creating a token programmatically
Two paths exist:
- Web UI — sign in at
/login, then mint a token at/settings/tokens. Returns the raw token exactly once. - Device-code grant — the standardised CLI / TV / IoT flow,
documented in the next section. The client_id must be in the
server's allowlist (default:
shithub-cli).
Device-code grant (RFC 8628)
The device-code grant lets a CLI obtain a PAT without prompting
the user to paste a token. It mirrors GitHub's /login/device/*
shape verbatim.
Endpoints
POST /login/device/code request a new authorization
POST /login/oauth/access_token poll until the user approves
GET /login/device browser verification page
/login/device/code and /login/oauth/access_token are CSRF-exempt
and accept application/x-www-form-urlencoded bodies.
1. Request a device code
POST /login/device/code
Content-Type: application/x-www-form-urlencoded
client_id=shithub-cli&scope=user%3Aread,repo%3Aread
{
"device_code": "f0a1b2c3...",
"user_code": "ABCD-EFGH",
"verification_uri": "https://shithub.example/login/device",
"verification_uri_complete": "https://shithub.example/login/device?user_code=ABCD-EFGH",
"expires_in": 900,
"interval": 5
}
client_idmust be in the server's allowlist. Default:shithub-cli.scopeis space- or comma-separated. Omit to receiveuser:read. Unknown scopes returninvalid_scope.device_codeis returned once and never echoed back; store it in client memory only.intervalis the minimum seconds between polls — seeslow_downbelow.
2. Show the user the code
Open the user's browser to verification_uri_complete (or
verification_uri if you can't open URLs with query strings).
The user enters the user_code, signs in if needed, then clicks
Authorize or Deny.
3. Poll the exchange endpoint
POST /login/oauth/access_token
Content-Type: application/x-www-form-urlencoded
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code
&client_id=shithub-cli
&device_code=f0a1b2c3...
Successful exchange (after the user has approved):
{
"access_token": "shithub_pat_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"token_type": "bearer",
"scope": "user:read,repo:read"
}
The access token is disclosed exactly once. A second exchange
of the same device_code returns invalid_grant even after
successful approval — clients must cache the token from the
first 200 response.
Error codes
All errors are HTTP 400 with a JSON body:
{ "error": "<code>", "error_description": "..." }
error |
Meaning |
|---|---|
authorization_pending |
User has not approved or denied yet. Keep polling at interval seconds. |
slow_down |
You polled inside the interval window. Increase your delay and retry. |
access_denied |
The user explicitly denied the request. Stop polling. |
expired_token |
The grant outlived its expires_in. Restart from /login/device/code. |
invalid_grant |
device_code unknown or already exchanged. |
unauthorized_client |
client_id is not in the server's allowlist. |
invalid_scope |
One or more requested scopes is not a known shithub scope. |
unsupported_grant_type |
The exchange request used a non-device-code grant_type. |
invalid_request |
Required form fields missing or body malformed. |
Lifecycle invariants
- The device_code is single-use for token disclosure. After a
successful exchange the row stays in the database for forensics
but further exchanges always return
invalid_grant. - The user_code is human-typeable: 8 characters from a 32-symbol
alphabet that excludes 0/O/1/I, formatted
XXXX-XXXX. The verification page also accepts the unhyphenated form. - Issued PATs carry the scopes requested at
/login/device/codetime. The token is named on the user's/settings/tokenspage with a recognisable label derived from the CLI'sUser-Agent.
View source
| 1 | # Authentication |
| 2 | |
| 3 | shithub's API authenticates calls with Personal Access Tokens. |
| 4 | PATs are minted via the web UI or, for CLI / non-browser clients, |
| 5 | through the [device-code grant](#device-code-grant-rfc-8628). |
| 6 | |
| 7 | ## Header |
| 8 | |
| 9 | ``` |
| 10 | Authorization: Bearer shp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
| 11 | ``` |
| 12 | |
| 13 | `Authorization: token shp_…` is accepted as a synonym for tools |
| 14 | that hard-code GitHub's older syntax. |
| 15 | |
| 16 | ## Token format |
| 17 | |
| 18 | PATs are 40 characters of base32 with the `shp_` prefix. They |
| 19 | match the regex: |
| 20 | |
| 21 | ``` |
| 22 | shp_[A-Za-z0-9]{40} |
| 23 | ``` |
| 24 | |
| 25 | Secret-scanning tools (GitHub's, GitGuardian's, etc.) recognize |
| 26 | this prefix. |
| 27 | |
| 28 | ## Failure modes |
| 29 | |
| 30 | | Status | Body | Cause | |
| 31 | |-------:|-----------------------------------|------------------------------------------------------| |
| 32 | | 401 | `{"error":"unauthenticated"}` | Missing or malformed header. | |
| 33 | | 401 | `{"error":"invalid token"}` | Token doesn't exist, was revoked, or has expired. | |
| 34 | | 401 | `{"error":"account suspended"}` | The owning account has been suspended by an admin. | |
| 35 | | 403 | `{"error":"insufficient scope"}` | Token is valid but lacks the scope this route needs. | |
| 36 | |
| 37 | ## Sessions |
| 38 | |
| 39 | The web UI uses session cookies, not PATs. Session cookies are |
| 40 | **not accepted** on `/api/v1/` — the API is PAT-only. This is a |
| 41 | deliberate choice: it keeps CSRF concerns off the API surface |
| 42 | and means every API caller is identified by an auditable token. |
| 43 | |
| 44 | ## Creating a token programmatically |
| 45 | |
| 46 | Two paths exist: |
| 47 | |
| 48 | 1. **Web UI** — sign in at `/login`, then mint a token at |
| 49 | `/settings/tokens`. Returns the raw token exactly once. |
| 50 | 2. **Device-code grant** — the standardised CLI / TV / IoT flow, |
| 51 | documented in the next section. The client_id must be in the |
| 52 | server's allowlist (default: `shithub-cli`). |
| 53 | |
| 54 | ## Device-code grant (RFC 8628) |
| 55 | |
| 56 | The device-code grant lets a CLI obtain a PAT without prompting |
| 57 | the user to paste a token. It mirrors GitHub's `/login/device/*` |
| 58 | shape verbatim. |
| 59 | |
| 60 | ### Endpoints |
| 61 | |
| 62 | ``` |
| 63 | POST /login/device/code request a new authorization |
| 64 | POST /login/oauth/access_token poll until the user approves |
| 65 | GET /login/device browser verification page |
| 66 | ``` |
| 67 | |
| 68 | `/login/device/code` and `/login/oauth/access_token` are CSRF-exempt |
| 69 | and accept `application/x-www-form-urlencoded` bodies. |
| 70 | |
| 71 | ### 1. Request a device code |
| 72 | |
| 73 | ``` |
| 74 | POST /login/device/code |
| 75 | Content-Type: application/x-www-form-urlencoded |
| 76 | |
| 77 | client_id=shithub-cli&scope=user%3Aread,repo%3Aread |
| 78 | ``` |
| 79 | |
| 80 | ```json |
| 81 | { |
| 82 | "device_code": "f0a1b2c3...", |
| 83 | "user_code": "ABCD-EFGH", |
| 84 | "verification_uri": "https://shithub.example/login/device", |
| 85 | "verification_uri_complete": "https://shithub.example/login/device?user_code=ABCD-EFGH", |
| 86 | "expires_in": 900, |
| 87 | "interval": 5 |
| 88 | } |
| 89 | ``` |
| 90 | |
| 91 | - `client_id` must be in the server's allowlist. Default: `shithub-cli`. |
| 92 | - `scope` is space- or comma-separated. Omit to receive |
| 93 | `user:read`. Unknown scopes return `invalid_scope`. |
| 94 | - `device_code` is returned once and never echoed back; store it |
| 95 | in client memory only. |
| 96 | - `interval` is the minimum seconds between polls — see |
| 97 | `slow_down` below. |
| 98 | |
| 99 | ### 2. Show the user the code |
| 100 | |
| 101 | Open the user's browser to `verification_uri_complete` (or |
| 102 | `verification_uri` if you can't open URLs with query strings). |
| 103 | The user enters the `user_code`, signs in if needed, then clicks |
| 104 | **Authorize** or **Deny**. |
| 105 | |
| 106 | ### 3. Poll the exchange endpoint |
| 107 | |
| 108 | ``` |
| 109 | POST /login/oauth/access_token |
| 110 | Content-Type: application/x-www-form-urlencoded |
| 111 | |
| 112 | grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code |
| 113 | &client_id=shithub-cli |
| 114 | &device_code=f0a1b2c3... |
| 115 | ``` |
| 116 | |
| 117 | Successful exchange (after the user has approved): |
| 118 | |
| 119 | ```json |
| 120 | { |
| 121 | "access_token": "shithub_pat_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", |
| 122 | "token_type": "bearer", |
| 123 | "scope": "user:read,repo:read" |
| 124 | } |
| 125 | ``` |
| 126 | |
| 127 | The access token is disclosed **exactly once**. A second exchange |
| 128 | of the same `device_code` returns `invalid_grant` even after |
| 129 | successful approval — clients must cache the token from the |
| 130 | first 200 response. |
| 131 | |
| 132 | ### Error codes |
| 133 | |
| 134 | All errors are HTTP 400 with a JSON body: |
| 135 | |
| 136 | ```json |
| 137 | { "error": "<code>", "error_description": "..." } |
| 138 | ``` |
| 139 | |
| 140 | | `error` | Meaning | |
| 141 | |------------------------|-------------------------------------------------------------------------------| |
| 142 | | `authorization_pending`| User has not approved or denied yet. Keep polling at `interval` seconds. | |
| 143 | | `slow_down` | You polled inside the `interval` window. Increase your delay and retry. | |
| 144 | | `access_denied` | The user explicitly denied the request. Stop polling. | |
| 145 | | `expired_token` | The grant outlived its `expires_in`. Restart from `/login/device/code`. | |
| 146 | | `invalid_grant` | `device_code` unknown or already exchanged. | |
| 147 | | `unauthorized_client` | `client_id` is not in the server's allowlist. | |
| 148 | | `invalid_scope` | One or more requested scopes is not a known shithub scope. | |
| 149 | | `unsupported_grant_type`| The exchange request used a non-device-code grant_type. | |
| 150 | | `invalid_request` | Required form fields missing or body malformed. | |
| 151 | |
| 152 | ### Lifecycle invariants |
| 153 | |
| 154 | - The device_code is single-use for token disclosure. After a |
| 155 | successful exchange the row stays in the database for forensics |
| 156 | but further exchanges always return `invalid_grant`. |
| 157 | - The user_code is human-typeable: 8 characters from a 32-symbol |
| 158 | alphabet that excludes 0/O/1/I, formatted `XXXX-XXXX`. The |
| 159 | verification page also accepts the unhyphenated form. |
| 160 | - Issued PATs carry the scopes requested at `/login/device/code` |
| 161 | time. The token is named on the user's `/settings/tokens` page |
| 162 | with a recognisable label derived from the CLI's `User-Agent`. |