Files
civitai__civitai/docs/auth/oauth-developer-docs.md
Manuel Emilio Urena 169e918a8c feat(account): finish the /user/account redesign behind accountSettingsV2 (#4727)
## What

Completes the `/user/account` redesign behind the `accountSettingsV2` flag. The earlier commits on this branch built the two-pane shell and converted three panes; this finishes the other five, then polishes the result against the Pencil canvas (`designs/user-account.pen`).

**OFF serves the legacy single-column page byte-identically.** See "Rollout" below.

## Panes

| Pane | Change |
|---|---|
| Overview | Tier badge art in the Membership tile (links `/user/membership`); Standing replaces the creator-score figure; username renders its nameplate + badge cosmetics; identity card stacks on mobile |
| Profile & Account | `ProfileCard` / `SocialProfileCard` flattened; Account standing shows the exact score; session refresh and delete are pointer rows, grouped |
| Preferences | Regrouped to Media playback / Generation / File preferences / Features; image format moved to File preferences; assistant folded into Features |
| Content & Browsing | Eye callout; mature-content rows; Topics as chips; hidden tags/users flattened |
| Creator | Placement, remix and metric-visibility sections; sticker inventory pointer moved inside Stickers |
| Membership & Billing | Subscription / payment methods / payouts flattened; gifts point at `/pricing/gift`; membership row stacks on mobile; empty states when the user has neither a membership nor a Creator Program payout config |
| Security & Apps | Sign-in methods, API keys, OAuth apps, connected apps flattened; create buttons on the section heading |
| Notifications | Delivery section; per-category icons; more room in an open category; `Other` sorted last |

## Decisions worth a reviewer's attention

- **Cards take a `flat` prop rather than being forked.** The legacy page mounts the same components while the flag is alive; two copies of a settings form is how one of them silently loses a field.
- **One rule per section.** Eight rows had nine dividers and read as a table. Rows are spaced instead.
- **`/user/account/overview` is a new URL.** On mobile the index renders the section *menu*, so an overview reachable only at the index has no way in. `AccountLayout` takes `isIndex` from the route now; inferring it from `section.path` rendered the menu at both URLs. Covered by a test — deleting the alias 404s that URL.
- **Standing thresholds moved to `accountStandingFromPoints`** (`strike.schema.ts`). Two surfaces show standing and it derives from active *points*, not the strike count.
- **The sticker-inventory pointer survives its host section's bail paths.** It is not gated on placement, so nesting it inside that section would drop it whenever the placement controls cannot render (flag off, or a failed spaces read).
- **`BrowsingCategories` switched to chips outright**, including the legacy card, rather than growing a variant prop — one rendering, no fork.
- **First use of a Tailwind `has-[…]` variant in this repo** (`SettingRow`, to keep switch rows inline at every width). Tailwind is 3.4.17, so it is supported.
- **Billing empty states are `flat`-only.** `SubscriptionCard` and `UserPaymentConfigurationCard` both returned `null` with nothing to show, which left the whole pane blank. They now offer the plans / the Creator Program instead — but only in the flat panes, so the legacy page keeps hiding them and stays byte-identical. Both reuse the metric-visibility upsell, extracted as `UpsellPanel`.

## Verification

Typecheck clean; no new lint warnings. Covering suites green: `account-sections` (17), `strike.service` + `process-strikes` (75), the four Account browser suites (24), and the notification suites (79).

`SettingsCard.earlyAdopter.browser` needed one assertion updated — it pinned the literal early-adopter copy. Kept its intent (the opt-in must explain itself) and split it into the promise and the caveat rather than loosening it.

Walked every pane at 1440px and 390px as a subscribed Creator Program account, and the billing/notification changes on a free account with no Creator Program.

## Not in this PR

- `designs/user-account.pen` has uncommitted local changes that predate this work; left alone deliberately.

## Rollout

`accountSettingsV2` → Flipt key `account-settings-v2`.

`availability: ['mod']` is the STATIC FALLBACK only — it decides nothing while Flipt answers, so it matters solely during a Flipt outage, where mods get the new shell and everyone else keeps the legacy page. The Flipt rollout is the on-switch: `account-settings-v2` is `enabled: false` with no rollouts today, so the page is off for everyone until a segment or threshold rollout is merged in `flipt-state`. Instant rollback = drop that rollout / set the threshold to 0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ
2026-09-09 16:08:19 -04:00

21 KiB
Raw Permalink Blame History

Civitai OAuth — Developer Guide

Overview

Civitai supports OAuth 2.0 for third-party applications to authenticate users and access the Civitai API on their behalf. This guide covers how to register an application, implement the authorization flow, and use access tokens.

Quick Start

  1. Go to Account Settings → OAuth Applications and register your app
  2. Implement the Authorization Code + PKCE flow
  3. Exchange the authorization code for an access token
  4. Use the access token as a Bearer token in API requests

Registering an Application

Visit your Account Settings → Security & Apps and find the OAuth Applications section. Click Register App and fill in:

  • App Name — displayed to users on the consent screen
  • Description — what your app does
  • Redirect URIs — where users are sent after authorization (must be exact match, HTTPS required in production)
  • Client Type:
    • Confidential — server-side apps that can keep a secret (you'll get a client secret)
    • Public — SPAs, mobile apps, CLI tools (no client secret, PKCE provides security)
  • Allowed Scopes — the maximum permissions your app can request

After registration, you'll receive a Client ID and (for confidential clients) a Client Secret. Save the secret — it won't be shown again.

Authorization Code Flow (with PKCE)

PKCE (Proof Key for Code Exchange) is required for all authorization requests.

Step 1: Generate PKCE Values

// Generate a random code_verifier (43-128 chars)
const codeVerifier = crypto.randomBytes(32).toString('base64url');

// Hash it to create the code_challenge
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');

Step 2: Redirect User to Authorization

Note: OAuth endpoints are served from the hub origin https://auth.civitai.com. The old https://civitai.com/api/auth/oauth/... URLs are legacy 308-redirect shims kept for back-compat — point new integrations at auth.civitai.com.

OIDC issuer: if you integrated "Sign in with Civitai" (OpenID Connect) against the https://civitai.com issuer, update your configured issuer to https://auth.civitai.com — the discovery doc, id_token.iss, and JWKS are all served from the hub now. (Plain OAuth API-token integrations are unaffected — only OIDC clients that validate iss need this change.)

GET https://auth.civitai.com/api/auth/oauth/authorize
  ?client_id=YOUR_CLIENT_ID
  &redirect_uri=https://yourapp.com/callback
  &response_type=code
  &scope=SCOPE_BITMASK
  &state=RANDOM_STATE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256

Parameters:

  • client_id — your app's client ID
  • redirect_uri — must exactly match a registered redirect URI
  • response_type — always code
  • scope — integer bitmask of requested permissions (see Scopes below)
  • state — random string for CSRF protection (required)
  • code_challenge — SHA-256 hash of your code_verifier, base64url-encoded
  • code_challenge_method — always S256

The user will see a consent screen listing the requested permissions. If they approve, they'll be redirected to your redirect_uri with an authorization code.

Step 3: Exchange Code for Token

POST https://auth.civitai.com/api/auth/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https://yourapp.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET  (confidential clients only)
&code_verifier=ORIGINAL_CODE_VERIFIER

Response:

{
  "access_token": "civitai_abc123...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "civitai_def456...",
  "scope": "33554431"
}

Step 4: Use the Access Token

GET https://auth.civitai.com/api/auth/oauth/userinfo
Authorization: Bearer civitai_abc123...

Response (standard OIDC UserInfo claims):

{
  "sub": "12345",
  "id": 12345,
  "username": "creator",
  "preferred_username": "creator",
  "name": "Creator",
  "picture": "https://...",
  "image": "https://...",
  "email": "creator@example.com",
  "email_verified": true
}

email and email_verified are released under the UserRead scope. UserRead is a mandatory baseline granted on every OAuth token — an app always needs to know whose account it's acting on — so the userinfo endpoint always works and email is present whenever the account has an email on file (unverified emails are still returned, with email_verified: false). Note that existing tokens issued before this change keep their original scope until they refresh — their access tokens (1h TTL) and the next refresh pick up the UserRead baseline automatically.

Or use it with any Civitai API/tRPC endpoint:

Authorization: Bearer civitai_abc123...

Refreshing Tokens

Access tokens expire after 1 hour. Use the refresh token to get a new one:

POST https://auth.civitai.com/api/auth/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=civitai_def456...
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET  (confidential clients only)

The old access and refresh tokens are revoked, and new ones are issued.

Device Authorization Flow

For CLI tools and devices without a browser:

Step 1: Request Device Code

POST https://auth.civitai.com/api/auth/oauth/device
Content-Type: application/x-www-form-urlencoded

client_id=YOUR_CLIENT_ID
&scope=SCOPE_BITMASK

Response:

{
  "device_code": "abc123...",
  "user_code": "ABCD-EFGH",
  "verification_uri": "https://auth.civitai.com/login/oauth/device",
  "verification_uri_complete": "https://auth.civitai.com/login/oauth/device?code=ABCD-EFGH",
  "expires_in": 900,
  "interval": 5
}

Step 2: Display Code to User

Show the user the user_code and verification_uri. They visit the URL in a browser and enter the code.

Step 3: Poll for Token

Poll the token endpoint every interval seconds:

POST https://auth.civitai.com/api/auth/oauth/device-token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:device_code
&device_code=abc123...
&client_id=YOUR_CLIENT_ID

Responses:

  • authorization_pending — user hasn't approved yet, keep polling
  • access_denied — user denied the request
  • expired_token — device code expired
  • Success — returns access token + refresh token

Over-polling returns HTTP 429 with {"error":"rate_limited"}, not slow_down. Poll no faster than interval; on a 429, add 5 seconds to your interval and keep polling. The limit is per client id, so every instance of an app shares one budget.

Client Credentials Flow

For server-to-server communication (no user context):

POST https://auth.civitai.com/api/auth/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&scope=SCOPE_BITMASK

The token acts on behalf of the client owner's account, scoped to the client's allowed permissions.

Revoking Tokens

POST https://auth.civitai.com/api/auth/oauth/revoke
Content-Type: application/x-www-form-urlencoded

token=civitai_abc123...
&token_type_hint=access_token
&client_id=YOUR_CLIENT_ID

What a call may revoke depends on how it authenticates, and the three paths differ:

  • Public client — send client_id. Revokes the token only if that client is the one it was issued to.
  • Confidential client — send client_id and client_secret. Authenticates as the client's owner account: it revokes tokens owned by that account whichever client issued them, and does not revoke a token it issued to anyone else.
  • Session cookie — revokes tokens owned by the signed-in user.

Revoking a refresh token also drops that user's access tokens for the same client — this is the sign-out call.

Always returns 200, even if the token was already revoked, so a 200 is not confirmation on its own.

Token Introspection

RFC 7662. Lets a first-party backend service ask the hub whether an opaque civitai_… access token is still live, and what it carries. Access tokens are random strings — nothing about them can be checked offline — so a service that is handed one has no alternative.

Not open to third-party apps. The caller must be a confidential OAuth client and its client id must be on the hub's OAUTH_INTROSPECTION_CLIENT_IDS allowlist. Everything else gets 401 {"error":"invalid_client"}. Today the only caller is link-service, which validates a Civitai Link client's token — the desktop app or the ComfyUI node pack — before minting an instance key. Ask us if you have a service that needs it.

POST https://auth.civitai.com/api/auth/oauth/introspect
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

token=civitai_abc123...

Client credentials go in HTTP Basic (above) or as client_id / client_secret in the body. The body may be form-encoded or JSON.

Active token:

{
  "active": true,
  "sub": "123",
  "username": "manuel",
  "scope": "159383553",
  "client_id": "civitai-link-desktop",
  "exp": 1756800000,
  "token_type": "Bearer"
}
  • scope is the decimal bitmask string this provider uses everywhere, not the RFC's space-separated list. Test a permission with a bitwise AND against the values in Scopes.
  • exp is Unix seconds, and is omitted for a token with no expiry.
  • client_id is the OAuth client the token was issued to.

Anything else returns 200 {"active": false} — unknown token, expired token, a refresh token, a personal API key (a different key type; only OAuth access tokens introspect as active), a missing token parameter, or a live token whose owner's account is closed or suspended. The endpoint never distinguishes those cases.

So active covers the subject as well as the token: active: true means the token is live and its owner is entitled to act, which is what lets a caller treat one introspection as a whole go/no-go.

Responses are Cache-Control: no-store, and the endpoint adds none of the wildcard CORS headers the token endpoints carry: this is a server-to-server call.

Scopes

Scopes are represented as a bitmask integer. Combine scopes with bitwise OR.

UserRead is always granted. Every issued token includes the UserRead bit regardless of what you request — an app always needs to identify the user it's acting on. You don't need to add it explicitly, and it can't be omitted.

Scope Value Description
UserRead 1 Read profile, settings & email (always granted)
UserWrite 2 Update profile & settings
ModelsRead 4 Browse & download models
ModelsWrite 8 Upload & edit models
ModelsDelete 16 Delete models
MediaRead 32 View images, videos & posts
MediaWrite 64 Upload media & create posts
MediaDelete 128 Delete media & posts
ArticlesRead 256 Read articles
ArticlesWrite 512 Create & edit articles
ArticlesDelete 1024 Delete articles
BountiesRead 2048 View bounties
BountiesWrite 4096 Create & manage bounties
BountiesDelete 8192 Delete bounties
AIServicesRead 16384 View generation & training history
AIServicesWrite 32768 Generate, train & scan
BuzzRead 65536 View buzz balance & history
CollectionsRead 131072 View collections
CollectionsWrite 262144 Manage collections
SocialWrite 524288 Follow, react, comment & review
SocialTip 1048576 Tip other users
NotificationsRead 2097152 Read notifications
NotificationsWrite 4194304 Manage notification preferences
VaultRead 8388608 View vault
VaultWrite 16777216 Manage vault
Full 33554431 All permissions

Opt-in scopes are not in Full. AppBlocksSubmit (33554432), AppBlocksDevTunnel (67108864) and LinkConnect (134217728) are granted only to clients whose registration lists them, and are deliberately excluded from Full so an existing key is never silently widened.

Common Scope Combinations

Use Case Scopes Value
Read-only browsing UserRead, ModelsRead, MediaRead, ArticlesRead, BountiesRead, BuzzRead, CollectionsRead, AIServicesRead, NotificationsRead, VaultRead Combine with OR
AI generation AIServicesWrite, AIServicesRead, BuzzRead 16384 | 32768 | 65536 = 114688
Publishing ModelsWrite, MediaWrite, ArticlesWrite + all reads Combine with OR

Endpoints

Endpoint Method Description
/api/auth/oauth/authorize GET Start authorization flow
/api/auth/oauth/token POST Exchange code for token, refresh token
/api/auth/oauth/userinfo GET Get authenticated user profile
/api/auth/oauth/revoke POST Revoke a token
/api/auth/oauth/introspect POST Introspect a token (allowlisted)
/api/auth/oauth/device POST Start device authorization
/api/auth/oauth/device-token POST Poll for device token
/.well-known/openid-configuration GET OpenID Connect discovery
/.well-known/jwks.json GET JSON Web Key Set (token-signing keys)

Endpoints are served from https://auth.civitai.com (e.g. https://auth.civitai.com/api/auth/oauth/authorize). Discovery: https://auth.civitai.com/.well-known/openid-configuration; JWKS: https://auth.civitai.com/.well-known/jwks.json. The matching https://civitai.com/api/auth/oauth/... paths still work for pre-cutover clients: authorize 308-redirects to the hub, and every other path is a transparent reverse proxy that forwards method, body and headers unchanged. Only authorize redirects, because a cross-origin redirect makes clients strip Authorization — which would break client_secret_basic and Bearer auth.

Rate Limits

Buckets are keyed differently per endpoint — check which one applies before sizing your client:

  • Token endpoint (/token): 20 requests/minute per IP
  • Device authorization (/device): 30 requests/minute per IP
  • Device token poll (/device-token): 20 requests/minute per device_code — one sign-in attempt — plus 240/minute per IP charged before the code is validated. Poll at the interval the authorization response gives you and you will not reach either
  • Authorization endpoint: 10 requests/minute per user
  • Revocation endpoint: 20 requests/minute per IP
  • Introspection endpoint: 60 requests/minute per client id, plus 120/minute per IP charged before client authentication

The hub sends no rate-limit headers and no Retry-After. A throttled request is 429 with {"error":"rate_limited"} — back off on the 429 itself.

Token Lifetime

  • Access tokens: 1 hour
  • Refresh tokens: 30 days
  • Authorization codes: 10 minutes
  • Device codes: 15 minutes

Security Best Practices

  1. Always use PKCE — required for all authorization code requests
  2. Validate the state parameter — prevent CSRF attacks
  3. Store tokens securely — never expose in URLs or client-side storage for confidential apps
  4. Use short-lived access tokens — refresh when they expire
  5. Request minimal scopes — only ask for what your app needs
  6. Register exact redirect URIs — no wildcards allowed

What tokens cannot do

Some Civitai actions are only available to session-authenticated users, regardless of token scope. Hitting these via a Bearer token returns 403 FORBIDDEN:

  • Tipping (buzz.tipUser)
  • Bounty creation (bounty.upsert)
  • Cosmetic shop purchases (cosmeticShop.purchaseShopItem, purchasableReward.purchase)
  • Comic chapter purchases (comics.purchaseChapterAccess)
  • Event donations (event.donate, donationGoal.donate)
  • Auction bids (auction.createBid)
  • Paid AI judge reviews (challenge.requestReview)
  • Paid game starts (games.chopped.start)
  • Model version early-access purchases (modelVersion.earlyAccessPurchase)
  • Creator-program bank/extract/withdraw (creator-program.*)
  • Direct user-to-club buzz transfers (buzz.depositClubFunds)

Buzz-spending operations that flow through the orchestrator (image generation, training, scanning, recommenders) are available to tokens — that's the entire point of the OAuth/API key surface. The orchestrator enforces buzz spend on its side using each token's per-subject budget.

/api/v1/me — token introspection

Hitting /api/v1/me with a Bearer token returns the user's identity plus token-specific fields:

{
  "id": 12345,
  "username": "...",
  "tier": "...",
  "status": "active" | "muted" | "banned",
  "isMember": false,
  "subscriptions": [],

  // UserRead is always granted, so these are present for any token
  "email": "creator@example.com",
  "emailVerified": true,

  // Present only when authenticated via a non-Full token
  "tokenScope": 4194303,
  "subject":   { "type": "apiKey" | "oauth", "id": <number | string> },
  "buzzLimit": [{ "type": "sliding", "limit": 5000, "window": "day", "unit": 1 }] | null
}

subject is the (type, id) pair Civitai's orchestrator buckets buzz spend by:

  • For User-type API keys, id is the numeric ApiKey.id.
  • For OAuth-issued tokens, id is the clientId — stable across access-token refresh rotations, so spend tracking persists when the access token rotates.

buzzLimit is null (or omitted) when the user has not configured a limit on this token. Otherwise it's an array of budgets that cap how much buzz this token may spend in the orchestrator.

Buzz spend limits — BuzzBudget[]

Each entry in buzzLimit is one of three discriminated variants:

type BuzzBudget =
  | { type: 'absolute'; currencies?: string[]; limit: number }
  | {
      type: 'sliding';
      currencies?: string[];
      limit: number;
      window: 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month';
      unit: number;
    }
  | { type: 'rollover'; currencies?: string[]; limit: number; cron: string };
  • absolute — hard cap, no time component.
  • sliding — rolling window of unit × window (e.g. window: 'day', unit: 7 = rolling 7-day window).
  • rollover — calendar-based reset driven by a cron expression. Cron syntax matches Hangfire Cronos.
  • Optional currencies restricts the cap to specific buzz pools (e.g. ["yellow"]).

Civitai's UI today only exposes a single sliding budget (limit + day/week/month period), but the JSON shape supports the full set. Programmatic clients with a Full-scope key can set any combination via the tRPC apiKey.setBuzzLimit and oauthConsent.setBuzzLimit mutations. A token cannot modify the limit on its own subject — use a different management key or session auth.