# Exo Key

https://exo.now/docs/platform/exo-key

> Exo is the only OAuth client and identity provider; Apps are relying parties that receive app-scoped RS256 tokens.



Sources: [ADR 0004](../../../docs/adr/0004-exo-id-central-identity-provider.md) and the
[Exo Key integration guide](../../../docs/exo-id.md). The guide is the integration reference; this
page summarizes it.

> **Naming:** Exo Key was called Exo ID until 2026-09-29. Technical identifiers, including the
> `/exo-id` endpoint paths, the `exo-id` ADR filename and the code, keep the old name until the
> code rename lands.

**Exo is the only OAuth client** registered with Google, GitHub, and (later) Apple. An App never
mounts Convex Auth or OAuth provider components, never holds `AUTH_PRIVATE_KEY`, `AUTH_JWKS`, or
provider client secrets, and never touches a provider console. &#x2A;*Onboarding an App means adding it
to `convex/lib/exoIdApps.ts`**, and that is the whole provider-side setup.

## Endpoints (staging) [#endpoints-staging]

| Purpose             | URL                                                                          |
| ------------------- | ---------------------------------------------------------------------------- |
| Issuer (`iss`)      | `https://frugal-eel-930.convex.site/exo-id`                                  |
| Authorize (browser) | `https://staging.exo.now/id/authorize`                                       |
| Token               | `POST https://frugal-eel-930.convex.site/exo-id/v1/token`                    |
| Revoke              | `POST https://frugal-eel-930.convex.site/exo-id/v1/revoke`                   |
| JWKS                | `https://frugal-eel-930.convex.site/exo-id/.well-known/jwks.json`            |
| Discovery           | `https://frugal-eel-930.convex.site/exo-id/.well-known/openid-configuration` |

Read the endpoints from the discovery document instead of hard-coding them. Production endpoints:
**TBD** (not recorded in the guide).

## Flow: authorization code + PKCE (S256) [#flow-authorization-code--pkce-s256]

<Steps>
  <Step>
    The App generates a `code_verifier`, its S256 `code_challenge`, and an opaque `state`.
  </Step>

  <Step>
    It redirects to `/id/authorize?app=…&redirect_uri=…&state=…&code_challenge=…&code_challenge_method=S256`.
    Exo validates the `app` and `redirect_uri` pair server-side. An invalid pair renders an error page
    and never redirects.
  </Step>

  <Step>
    After sign-in, Exo mints a one-time code (valid for 60 seconds, single use, stored only as a
    SHA-256 hash) and redirects back with `code` and `state`.
  </Step>

  <Step>
    The App checks `state` and exchanges the code at the token endpoint. It refreshes before
    `expires_in`; refresh tokens rotate. Replaying the previous token within 30 seconds returns the
    same new token; any other reuse of a rotated token signs the whole session out.
  </Step>

  <Step>
    Sign-out posts the refresh token to the revoke endpoint, which always returns 200.
  </Step>
</Steps>

## Tokens [#tokens]

* **Access token:** RS256 JWT, 900 seconds. Claims: `iss`, `aud` (the App name), `sub` (the Exo
  user id, stable across Apps), `email`, `email_verified`, `name`, `iat`, and `exp`.
* **Refresh token:** opaque, 30 days, rotating.
* **Errors:** HTTP 400 with `invalid_request`, `invalid_grant`, or `unsupported_grant_type`. Any
  redemption attempt consumes the code. Too many token requests return HTTP 429 with
  `Retry-After`.

Relying Apps verify tokens in their own Convex backend with a `customJwt` provider that points at
the Exo Key issuer and JWKS. `ctx.auth.getUserIdentity()` then returns the Exo user id, email, and
name.

## Registered Apps [#registered-apps]

| App           | Redirect URIs (summary)                                                 |
| ------------- | ----------------------------------------------------------------------- |
| `observatory` | staging subpath, `observatory.bio`                                      |
| `construct`   | `staging.construct.page`, `construct.page` (server-side cookie session) |
| `rampart`     | staging subpath, `rampart.fit`                                          |
| `dropship`    | staging subpath, `dropship.wtf`                                         |
| `universe`    | staging subpath, native `universe://auth/callback`                      |

Redirect URIs match **exactly**, with no prefix, wildcard, query, or trailing-slash tolerance.
Origins control CORS on the token and revoke endpoints.

## Boundaries [#boundaries]

* **Authorization stays in the App (v1).** Exo Key proves who the person is. Allowlists, roles, and
  tenant grants are enforced by the App on every request.
* **Separate signing keys.** Exo Key signs with `EXO_ID_PRIVATE_KEY` and `EXO_ID_JWKS`, independent
  of Exo's own session keys. Rotation publishes the new key alongside the old one, waits at least
  the JWKS cache time (5 minutes) plus the access-token lifetime, then drops the old key.
* **Hard dependency.** Exo's Convex deployment is on the sign-in path for the whole portfolio.
  Its availability and key rotation are platform responsibilities.

## Cost notes [#cost-notes]

No license fee (Convex Auth is open source). Marginal Convex usage: **TBD**. The decision to leave
Clerk cited its per-MAU pricing
([ADR 0003](../../../docs/adr/0003-convex-auth-v2-identity-plane.md)).

## Status [#status]

Live on staging for the five registered Apps. Clerk retirement is done: EXO carries no Clerk code,
package, or variable, and its own sign-in and staging gateway run on Exo Key
([ADR 0012](../../../docs/adr/0012-clerk-removed-convex-auth-everywhere.md),
[roadmap: convex-auth](../../../roadmap/items/convex-auth.md)). Centralized grants are future
work; staging tester grants are declared in `EXO_STAGING_GRANTS`.
