OAuth

Let people sign in to your app with their haunt.gg account.

haunt.gg is an OAuth 2.1 and OpenID Connect provider. Register an app in your dashboard and you can put a Log in with haunt.gg button in your own project: the user approves what your app may read, and you get an access token and an OpenID Connect id_token — plus a refresh token if you asked for one.

There is nothing bespoke to implement. Point any OIDC client library at the issuer https://haunt.gg and it configures itself from the discovery document; authorization code + PKCE, refresh, userinfo, revocation and introspection all behave exactly as the specs say.

Register your app

Apps are created in the haunt.gg dashboard under Developer (https://haunt.gg/dashboard/developer). Dynamic client registration is not available, and there is no public-client mode: every app is confidential and gets a client_id plus a client_secret.

The client secret is shown once, right after you create the app or reset it. It is stored hashed, so nobody — not even haunt.gg staff — can show it to you again. Resetting the secret takes effect immediately, with no grace period: everything still using the old secret starts failing at the token endpoint the moment you confirm.

You also pick the app's redirect URLs — up to 10, each at most 250 characters. They are matched exactly, path included: no wildcards, no # fragment. Plain http is allowed only on 127.0.0.1 and localhost, everything else must be https. A native app may instead register a private-use scheme, which has to be reverse-domain style — it needs a dot, e.g. gg.haunt.myapp:/callback.

If a native or CLI app listens on a random port, register http://127.0.0.1/… rather than http://localhost/…: the port is ignored only for the loopback IP, never for the hostname. Details.

The flow

1

Send the user to haunt.gg

Redirect the browser to the authorization endpoint with your client_id, one of your registered redirect_uri values, response_type=code, the scope list, a state value you generate, and a PKCE challenge. PKCE with S256 is mandatory for every app.

2

The user signs in and approves

haunt.gg handles the login and shows its own consent screen listing exactly the permissions your app requested. If the same user already approved the same set of permissions, the screen is skipped.

3

haunt.gg calls your redirect URL

The browser comes back to your redirect_uri with code, your state, and an iss parameter you should verify equals https://haunt.gg.

4

Exchange the code for tokens

From your server, POST the code together with your client_secret and the PKCE verifier to the token endpoint. You get an access token, an id_token, and — if you asked for offline_access — a refresh token.

5

Read the user

Call the userinfo endpoint with the access token, or just read the claims out of the id_token.

The token exchange must happen server-side. /api/auth/oauth2/token sends no CORS headers and answers no preflight, and every app needs its client secret to authenticate — a browser-only exchange cannot work.

Permissions

There are five. An app can never ask for more than it registered — requesting anything else fails the whole request with invalid_scope.

ScopeOn the consent screenWhat it gives you
openidSign inThe id_token, and permission to call userinfo. Without it there is no OpenID Connect flow, so always ask for it.
identifyBasic profileWho the user is — sub, uid, name, preferred_username, picture, profile. Every claim in detail.
emailEmail addressemail and email_verified.
connectionsConnected accountsThe Discord and Last.fm accounts the user linked to haunt.gg.
offline_accessRefresh sessionA refresh token. Leave it out and the token response contains no refresh_token at all — once the access token expires an hour later, the user has to walk through the flow again.

Most integrations want openid identify, plus offline_access if the app does anything on the user's behalf while they are away.

Email address and Connected accounts are locked. Your app can only register them once haunt.gg staff unlocked them for your account — open a support ticket at https://haunt.gg/dashboard/support/new and tell us what your app does. The other three are available to everyone.

The OIDC standard profile scope does not exist here — ask for identify.

Endpoints

Token lifetimes

TokenLives forNotes
Authorization code10 minutesSingle use. The login and consent step has to finish inside the same window.
Access token1 hourOpaque by default — treat it as a random string, not as a JWT.
Refresh token30 daysSliding: every refresh issues a new one and resets the 30 days.
id_token10 hoursSigned with EdDSA. Keys are at https://haunt.gg/api/auth/jwks.

What is not supported

  • Dynamic client registration — apps are created in the dashboard, and registration_endpoint is absent from the discovery document.
  • Public clientstoken_endpoint_auth_methods_supported never contains none. Every app authenticates with its secret.
  • The client_credentials grant — it appears in grant_types_supported, but haunt.gg apps are registered for authorization_code and refresh_token only, so it always answers unauthorized_client.
  • RP-initiated logoutend_session_endpoint is advertised but not enabled for haunt.gg apps. Sign the user out in your own app instead.
  • plain PKCE — only S256 is accepted.

When the user takes access back

A user can disconnect your app at any time under Account → Settings → Connections. That deletes their approval and every access and refresh token your app holds for them, so your next refresh answers invalid_grant. Send them through the authorization flow again to reconnect.

haunt.gg staff can also suspend an app. A suspended app is refused at the authorization endpoint with client_disabled, every token grant answers invalid_client, and all of its tokens are deleted — but user approvals are kept, so restoring the app does not re-prompt everyone.

Rate limits

100 requests per minute per IP address, counted separately for each endpoint — so a burst of userinfo calls never eats into your token budget. Going over answers 429 with an X-Retry-After header (seconds to wait) and the body {"message":"Too many requests. Please try again later."}. The discovery document is cached and not rate limited.