Fades Docs
Fades Identity · v1

Sign users in with Fades.

Fades Identity is an OAuth 2.0 and OpenID Connect provider. Add “Sign in with Fades” to your app using the Authorization Code flow with PKCE, then read the user’s profile and email with a single API call.

Basics

Base URLs

Everything your app calls lives under one base URL. Users approve access on the Fades sign-in site.

NameURLUsed for
API basehttps://api.fades.lol/oauthToken, UserInfo, revoke, JWKS, app management
Issuerhttps://api.fades.lolThe iss value inside ID tokens
Sign-in sitehttps://auth.fades.lolWhere users sign in and approve your app
🔐
PKCE requiredEvery authorization uses S256 PKCE, for public and confidential apps.
🪪
OpenID ConnectRS256-signed ID tokens, a JWKS endpoint and a UserInfo endpoint.
♻️
Rotating refreshRefresh tokens last 30 days and rotate on every use.
🧩
Three scopesopenid, profile and email. Nothing else to configure.
Getting started

Quickstart

From zero to a signed-in user in about five steps.

  1. Register your app. Create an app to get a client_id (and a client_secret if it’s a server-side app). Add your callback URL as a redirect URI. See Registering an app.
  2. Send the user to Fades. Generate a PKCE verifier and challenge, then redirect the browser to /oauth/authorize.
  3. Receive the code. After the user approves, Fades redirects back to your redirect URI with ?code=…&state=…. The code works once and expires in 60 seconds.
  4. Exchange the code. POST the code and your PKCE verifier to /oauth/token from your server. You get an access token, a refresh token and an ID token.
  5. Load the profile. Call /oauth/userinfo with the access token, or verify the ID token.

The shortest working version using the JavaScript client:

import { FadesIdentity, createPkcePair, randomString } from "./fades-identity.js";

const fades = new FadesIdentity({
  clientId: "fades_YOUR_CLIENT_ID",
  clientSecret: process.env.FADES_CLIENT_SECRET, // server only. Omit for public apps
  redirectUri: "https://example.com/auth/callback",
});

// 1. Start sign-in
const { verifier, challenge } = await createPkcePair();
const state = randomString(16);
// ...save verifier + state (session or cookie), then redirect to:
const url = fades.authorizationUrl({ challenge, state });

// 2. In your callback route
const tokens = await fades.exchangeCode({ code, verifier });
const user = await fades.userinfo(tokens.access_token);
console.log(user.preferred_username, user.email);
Getting started

Core concepts

Apps (clients)

An app is how Fades knows who is asking. Each app has a client_id that starts with fades_ and a list of exact-match redirect URIs.

TypeHas a secret?Use it for
ConfidentialYes. Shown once at creationServer-side apps: Next.js API routes, Express, Python backends. The secret never leaves your server.
PublicNo. Relies on PKCESingle-page apps, desktop apps and mobile apps that can’t keep a secret.

Scopes

Scopes decide what ends up in the ID token and UserInfo response. If you omit scope, it defaults to openid.

ScopeGrantsClaims returned
openidRequired for sign-in. Enables the ID token and UserInfosub
profileUsername, display name, avatarname, preferred_username, picture
emailEmail address and verification statusemail, email_verified

PKCE

PKCE (Proof Key for Code Exchange) ties the authorization code to the app instance that asked for it, so a stolen code is useless. You create a random code verifier, send its SHA-256 hash (the code challenge) when you start sign-in, and send the verifier itself when you redeem the code.

  • Verifier: 43–128 characters from A–Z a–z 0–9 - . _ ~. Generate 32 random bytes and base64url-encode them.
  • Challenge: base64url(SHA-256(verifier)), without = padding. Always send code_challenge_method=S256. plain is not supported.
Guides

Authorization flow

The Authorization Code flow with PKCE, step by step.

  1. Your app creates verifier, challenge, state (and optionally nonce) and stores them where the callback can find them.
  2. Browser → Fades GET /oauth/authorize?… Fades validates the app and redirect URI, then forwards the user to auth.fades.lol.
  3. User signs in and approves or denies the requested scopes.
  4. Fades → your callback The browser lands on your redirect URI with code and state (or error and state).
  5. Your server → Fades POST /oauth/token with the code, redirect URI and code_verifier.
  6. Fades → your server returns access_token, refresh_token and, for openid, an id_token.
  7. Your server → Fades GET /oauth/userinfo with the access token to load the profile.
Always check state. Compare the state on the callback with the one you saved. If it doesn’t match, stop. That request wasn’t started by your app.
The redirect URI must match exactly. The redirect_uri you send to /authorize and /token must be identical, character for character, to one registered on the app, including trailing slashes.
Guides

Registering an app

Create an app with POST /oauth/clients while signed in to Fades (the dashboard does this for you). The response contains the clientId and, for confidential apps, the clientSecret.

The client secret is shown once. Fades stores only a hash of it. If you lose it, delete the app and create a new one.

Redirect URI rules

  • 1 to 10 redirect URIs per app, each an absolute URL.
  • https:// is required. http:// is allowed only for localhost and 127.0.0.1 (for development).
  • No URL fragments (#…) and no credentials in the URL (user:pass@).
  • You can have up to 20 apps per account. App names are trimmed to 60 characters.

Which type should I pick?

  • Do you have a backend that can keep an environment variable private? Create a confidential app and do the token exchange there.
  • Pure front-end or native app? Create a public app ("public": true). PKCE protects the exchange.
Guides

ID tokens

When you request the openid scope, the token response includes an id_token: a JWT signed with RS256. It proves who signed in. Use it right after the exchange to establish your own session. It lives for only 5 minutes.

Header

{ "alg": "RS256", "typ": "JWT", "kid": "fades-rsa-1" }

Claims

ClaimAlways?Description
issYesIssuer: https://api.fades.lol
subYesThe user’s stable, unique ID. Use this as your user key
audYesYour client_id
iat, expYesIssued-at and expiry, in Unix seconds
nonceIf you sent oneThe nonce from your authorize request
nameprofileDisplay name (falls back to the username)
preferred_usernameprofileThe user’s Fades username
pictureprofile, if setAvatar URL
emailemail, if setEmail address
email_verifiedemailtrue or false

Verifying it

Fetch the public key from the JWKS endpoint, check the RS256 signature, then check iss, aud, exp and nonce. A ready-made example is in Verify an ID token. If you only need the profile, the UserInfo endpoint is simpler.

Refreshing a token (grant_type=refresh_token) does not return a new ID token. Use UserInfo for fresh profile data.
Guides

Tokens & refresh

TokenLifetimeNotes
Authorization code60 secondsSingle use. Consumed the moment you redeem it
Access token1 hour (expires_in: 3600)Opaque string. Send as Authorization: Bearer …
Refresh token30 daysOpaque. Rotates: every refresh returns a new one and kills the old one
ID token5 minutesRS256 JWT. For establishing a session, not for API calls

Rotation in practice

Each time you call the token endpoint with grant_type=refresh_token, Fades issues a new access token and a new refresh token, and the one you used stops working. Always save the new refresh token. If two requests race with the same refresh token, one wins and the other gets invalid_grant.

Signing users out

Call POST /oauth/revoke with the access or refresh token. Users can also disconnect your app themselves from their account (see Disconnect app), which invalidates all your tokens for that user.

API reference
GET/oauth/.well-known/openid-configuration

Discovery

Returns the provider metadata: endpoint URLs, supported scopes, signing algorithm and more. OpenID Connect libraries can use this to configure themselves. Cached for 1 hour.

No authenticationPublic
curl https://api.fades.lol/oauth/.well-known/openid-configuration

Response · 200

{
  "issuer": "https://api.fades.lol",
  "authorization_endpoint": "https://api.fades.lol/oauth/authorize",
  "token_endpoint": "https://api.fades.lol/oauth/token",
  "userinfo_endpoint": "https://api.fades.lol/oauth/userinfo",
  "jwks_uri": "https://api.fades.lol/oauth/jwks",
  "revocation_endpoint": "https://api.fades.lol/oauth/revoke",
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid", "profile", "email"],
  "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"],
  "code_challenge_methods_supported": ["S256"],
  "claims_supported": ["iss", "sub", "aud", "iat", "exp", "nonce", "name", "preferred_username", "picture", "email", "email_verified"]
}
GET/oauth/jwks

JWKS

The public key used to verify ID token signatures, as a JSON Web Key Set. Cached for 1 hour, so fetch it with a library that caches (like jose) rather than on every login.

No authenticationPublic
curl https://api.fades.lol/oauth/jwks

Response · 200

{
  "keys": [
    {
      "kty": "RSA",
      "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
      "e": "AQAB",
      "kid": "fades-rsa-1",
      "use": "sig",
      "alg": "RS256",
      "key_ops": ["verify"]
    }
  ]
}
Your real n value will differ. Match tokens to keys using the kid in the token header.

Errors

500 {"error":"server_error"} means the provider’s signing key isn’t available. This is a server-side problem, not something your app can fix.

GET/oauth/authorize

Authorize

Starts sign-in. Send the user’s browser here (a redirect or link, not a fetch). Fades validates the request and forwards the user to the sign-in and consent page. When they decide, they’re sent back to your redirect URI.

Browser redirectNo authentication header

Query parameters

NameDescription
client_idrequiredYour app’s ID, for example fades_a1B2c3D4e5F6g7H8
redirect_urirequiredOne of the redirect URIs registered on the app. Exact match
response_typerequiredMust be code
code_challengerequiredbase64url(SHA-256(verifier)), 43–128 chars of A–Z a–z 0–9 - _
code_challenge_methodrequiredMust be S256
scopeoptionalSpace-separated list from openid profile email. Defaults to openid
staterecommendedRandom value you generate. Returned unchanged on the redirect back. Use it to block CSRF
nonceoptionalRandom string (max 1024 chars). Copied into the ID token to prevent replay

Example request

https://api.fades.lol/oauth/authorize
  ?client_id=fades_a1B2c3D4e5F6g7H8
  &redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fcallback
  &response_type=code
  &scope=openid%20profile%20email
  &state=Zp3k9xQ1mT8vLw2A
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &nonce=n-0S6_WzA2Mj

Redirect back to your app

https://example.com/auth/callback?code=Qm9uZ2pvdXJfZmFkZXM&state=Zp3k9xQ1mT8vLw2A
https://example.com/auth/callback?error=access_denied&state=Zp3k9xQ1mT8vLw2A
Unknown app or unregistered redirect URI: Fades never redirects to an unverified address. Instead the user lands on auth.fades.lol/oauth/error with a message (Unknown app. or This redirect address isn't registered for the app.). Your callback is never called, so check these first when sign-in “does nothing”.

Errors returned on the redirect

errorCause
unsupported_response_typeresponse_type wasn’t code
invalid_requestMissing or malformed code_challenge, method isn’t S256, or nonce is too long or not a string
invalid_scopeA scope other than openid, profile or email
access_deniedThe user pressed “Deny”
POST/oauth/token

Token · exchange a code

Trades the authorization code for tokens. Call this from your server (confidential apps), or from the client for public apps. The code is consumed on success.

Content-Type: application/jsonClient authentication60 requests / minute

Client authentication

Pick one method. Public apps (no secret) send only client_id.

MethodHow
client_secret_postPut client_id and client_secret in the request body
client_secret_basicAuthorization: Basic base64(urlencode(client_id):urlencode(client_secret))
nonePublic apps only: client_id in the body, PKCE does the rest

Body parameters

NameDescription
grant_typerequiredauthorization_code
coderequiredThe code from the redirect
redirect_urirequiredSame value you sent to /authorize
code_verifierrequiredThe original PKCE verifier (43–128 chars of A–Z a–z 0–9 - . _ ~)
client_idrequiredYour app’s ID (unless using Basic auth)
client_secretconfidential onlyYour app’s secret (unless using Basic auth)
curl -X POST https://api.fades.lol/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "client_id": "fades_a1B2c3D4e5F6g7H8",
    "client_secret": "YOUR_CLIENT_SECRET",
    "code": "Qm9uZ2pvdXJfZmFkZXM",
    "redirect_uri": "https://example.com/auth/callback",
    "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
  }'
const res = await fetch("https://api.fades.lol/oauth/token", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    grant_type: "authorization_code",
    client_id: process.env.FADES_CLIENT_ID,
    client_secret: process.env.FADES_CLIENT_SECRET,
    code,
    redirect_uri: "https://example.com/auth/callback",
    code_verifier: verifier,
  }),
});
const tokens = await res.json();
if (!res.ok) throw new Error(tokens.error);
import os, requests

res = requests.post("https://api.fades.lol/oauth/token", json={
    "grant_type": "authorization_code",
    "client_id": os.environ["FADES_CLIENT_ID"],
    "client_secret": os.environ["FADES_CLIENT_SECRET"],
    "code": code,
    "redirect_uri": "https://example.com/auth/callback",
    "code_verifier": verifier,
}, timeout=20)
tokens = res.json()
res.raise_for_status()

Response · 200

{
  "access_token": "dGhpc19pc19hbl9hY2Nlc3NfdG9rZW4",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "dGhpc19pc19hX3JlZnJlc2hfdG9rZW4",
  "scope": "openid profile email",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZhZGVzLXJzYS0xIn0..."
}

id_token is included only when openid was granted. Responses are sent with Cache-Control: no-store.

Errors

See Error codes for the full list. The common ones are invalid_client (401), invalid_grant (400) and server_error (500).

Codes are single use. If the exchange fails for any reason after the code was validated, start the sign-in again to get a fresh code.
POST/oauth/token

Token · refresh

Gets a new access token using a refresh token. The refresh token rotates: the response contains a new one, and the one you sent is invalidated.

Same endpoint as aboveClient authentication60 requests / minute

Body parameters

NameDescription
grant_typerequiredrefresh_token
refresh_tokenrequiredThe refresh token you were given
client_id, client_secretSame authentication as the code exchange. The token must belong to the same app
curl -X POST https://api.fades.lol/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "refresh_token",
    "client_id": "fades_a1B2c3D4e5F6g7H8",
    "client_secret": "YOUR_CLIENT_SECRET",
    "refresh_token": "dGhpc19pc19hX3JlZnJlc2hfdG9rZW4"
  }'
const tokens = await fades.refresh(savedRefreshToken);
// Save BOTH new values. The old refresh token no longer works.
save({ access: tokens.access_token, refresh: tokens.refresh_token });
res = requests.post("https://api.fades.lol/oauth/token", json={
    "grant_type": "refresh_token",
    "client_id": CLIENT_ID,
    "client_secret": CLIENT_SECRET,
    "refresh_token": saved_refresh_token,
}, timeout=20)
tokens = res.json()

Response · 200

{
  "access_token": "bmV3X2FjY2Vzc190b2tlbg",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "bmV3X3JlZnJlc2hfdG9rZW4",
  "scope": "openid profile email"
}

No id_token is returned on refresh. Scopes stay the same as the original grant.

GET/oauth/userinfo

UserInfo

Returns the signed-in user’s profile. Which fields appear depends on the scopes the user granted. Requires the openid scope.

Bearer access token60 requests / minute
curl https://api.fades.lol/oauth/userinfo \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
const user = await fades.userinfo(tokens.access_token);
// or with plain fetch:
const res = await fetch("https://api.fades.lol/oauth/userinfo", {
  headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const profile = await res.json();
res = requests.get(
    "https://api.fades.lol/oauth/userinfo",
    headers={"Authorization": f"Bearer {access_token}"},
    timeout=20,
)
profile = res.json()

Response · 200

{
  "sub": "37f3d33f-7ac7-403c-a17c-346c3083db68",
  "name": "Ada Lovelace",
  "preferred_username": "ada",
  "picture": "https://example.com/avatars/ada.png",
  "email": "[email protected]",
  "email_verified": true
}
FieldNeeds scopeNotes
subopenidAlways present. Use this as the user’s ID in your database
name, preferred_usernameprofilename falls back to the username if no display name is set
pictureprofileOmitted when the user has no avatar
emailemailOmitted when the account has no email
email_verifiedemailBoolean

Errors

A missing, unknown or expired token, a token without the openid scope, or a deleted user all return 401 {"error":"invalid_token"} with a WWW-Authenticate: Bearer error="invalid_token" header. Refresh the access token and retry once.

POST/oauth/revoke

Revoke

Invalidates a token. Pass either the access token or the refresh token. Fades removes the matching token record, so both tokens in the pair stop working. Use this when a user signs out of your app.

Client authentication60 requests / minute
NameDescription
tokenrequiredAn access token or refresh token that belongs to your app
client_id, client_secretSame authentication as the token endpoint
curl -X POST https://api.fades.lol/oauth/revoke \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "fades_a1B2c3D4e5F6g7H8",
    "client_secret": "YOUR_CLIENT_SECRET",
    "token": "dGhpc19pc19hX3JlZnJlc2hfdG9rZW4"
  }'
await fades.revoke(tokens.refresh_token);
requests.post("https://api.fades.lol/oauth/revoke", json={
    "client_id": CLIENT_ID,
    "client_secret": CLIENT_SECRET,
    "token": refresh_token,
}, timeout=20)

Response · 200

{}

An unknown or already-revoked token still returns 200 {}, so you can’t use this endpoint to probe which tokens exist. Only failed client authentication returns an error (401 invalid_client).

App management
POST/oauth/clients

Create an app

Registers a new app owned by the signed-in user. The management endpoints (apps and connected apps) use the user’s Fades session, not an app’s client credentials. They’re what the dashboard calls on the user’s behalf.

Signed-in Fades session30 requests / 10 min
FieldDescription
namerequiredShown to users on the consent screen. Trimmed to 60 characters
redirectUrisrequiredArray of 1–10 URLs. HTTPS, or http://localhost / http://127.0.0.1 for development
publicoptionaltrue creates a public app with no secret. Default false
const res = await fetch("https://api.fades.lol/oauth/clients", {
  method: "POST",
  credentials: "include", // sends the user's Fades session
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    name: "My Cool App",
    redirectUris: ["https://example.com/auth/callback", "http://localhost:3000/auth/callback"],
    public: false,
  }),
});
const app = await res.json();

Response · 201

{
  "success": true,
  "clientId": "fades_a1B2c3D4e5F6g7H8",
  "clientSecret": "k3Jx9...store-this-now...Qw2"
}

clientSecret is null for public apps. Save it immediately: it can’t be shown again.

Errors · 400

error messageCause
Give your app a name.name missing or blank
Add 1 to 10 valid redirect URLs (HTTPS, or localhost for development).No URIs, more than 10, or an invalid one (bad URL, fragment, embedded credentials, plain http on a non-localhost host)
You can have up to 20 apps.App limit reached. Delete one first
GET/oauth/clients

List your apps

Returns the apps owned by the signed-in user. Secrets are never returned.

Signed-in Fades session
const res = await fetch("https://api.fades.lol/oauth/clients", { credentials: "include" });
const { clients } = await res.json();

Response · 200

{
  "success": true,
  "clients": [
    {
      "id": "fades_a1B2c3D4e5F6g7H8",
      "name": "My Cool App",
      "redirectUris": ["https://example.com/auth/callback"],
      "public": false,
      "createdAt": "2026-10-09T06:12:44.000Z"
    }
  ]
}
DELETE/oauth/clients/:id

Delete an app

Permanently deletes one of your apps. All of its access tokens, refresh tokens and unused authorization codes are removed at the same time, so every user is signed out of that app.

Signed-in Fades session30 requests / 10 min
await fetch("https://api.fades.lol/oauth/clients/fades_a1B2c3D4e5F6g7H8", {
  method: "DELETE",
  credentials: "include",
});

Responses

{ "success": true }
{ "success": false, "error": "App not found." }

404 is returned both when the app doesn’t exist and when it belongs to someone else.

Connected apps
GET/oauth/grants

List connected apps

Lists the apps the signed-in user has authorized, one entry per app, with the scopes granted. Used for an “Authorized apps” settings page.

Signed-in Fades session
const res = await fetch("https://api.fades.lol/oauth/grants", { credentials: "include" });
const { apps } = await res.json();

Response · 200

{
  "success": true,
  "apps": [
    {
      "clientId": "fades_a1B2c3D4e5F6g7H8",
      "name": "My Cool App",
      "scopes": ["openid", "profile", "email"],
      "since": "2026-10-09T06:20:01.000Z"
    }
  ]
}
DELETE/oauth/grants/:clientId

Disconnect an app

Revokes every token a specific app holds for the signed-in user. The app can no longer call UserInfo or refresh. The user would have to approve it again to reconnect.

Signed-in Fades session
await fetch("https://api.fades.lol/oauth/grants/fades_a1B2c3D4e5F6g7H8", {
  method: "DELETE",
  credentials: "include",
});

Response · 200

{ "success": true }
SDKs & examples

JavaScript client

A zero-dependency client for browsers and Node 19+. Copy it into your project as fades-identity.js. It isn’t published to npm, so there’s nothing to install.

// fades-identity.js: zero-dependency client for Fades Identity.
// Works in browsers and Node 19+ (needs fetch + Web Crypto).

const b64url = (bytes) =>
  btoa(String.fromCharCode(...new Uint8Array(bytes)))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");

/** Random URL-safe string. 32 bytes gives 43 characters. */
export function randomString(bytes = 32) {
  return b64url(crypto.getRandomValues(new Uint8Array(bytes)));
}

/** Creates a PKCE verifier and its S256 challenge. */
export async function createPkcePair() {
  const verifier = randomString(32);
  const digest = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(verifier)
  );
  return { verifier, challenge: b64url(digest) };
}

export class FadesError extends Error {
  constructor(status, body) {
    super(body.error_description || body.error || "Request failed");
    this.name = "FadesError";
    this.status = status;          // HTTP status
    this.error = body.error;       // e.g. "invalid_grant"
    this.requestId = body.request_id || null; // present on server_error
  }
}

export class FadesIdentity {
  constructor({ clientId, clientSecret, redirectUri, issuer = "https://api.fades.lol" }) {
    this.clientId = clientId;
    this.clientSecret = clientSecret; // leave undefined for public apps
    this.redirectUri = redirectUri;
    this.base = issuer.replace(/\/+$/, "") + "/oauth";
  }

  /** Build the URL to send the user's browser to. */
  authorizationUrl({ challenge, state, nonce, scope = "openid profile email" }) {
    const params = Object.entries({
      client_id: this.clientId,
      redirect_uri: this.redirectUri,
      response_type: "code",
      scope,
      state,
      nonce,
      code_challenge: challenge,
      code_challenge_method: "S256",
    }).filter(([, value]) => value);

    return `${this.base}/authorize?${new URLSearchParams(params)}`;
  }

  async #post(path, body) {
    const res = await fetch(this.base + path, {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify({
        client_id: this.clientId,
        ...(this.clientSecret ? { client_secret: this.clientSecret } : {}),
        ...body,
      }),
    });
    const data = await res.json().catch(() => ({}));
    if (!res.ok) throw new FadesError(res.status, data);
    return data;
  }

  /** Trade the callback code for tokens. */
  exchangeCode({ code, verifier }) {
    return this.#post("/token", {
      grant_type: "authorization_code",
      code,
      redirect_uri: this.redirectUri,
      code_verifier: verifier,
    });
  }

  /** Get new tokens. Save the NEW refresh token. The old one is dead. */
  refresh(refreshToken) {
    return this.#post("/token", {
      grant_type: "refresh_token",
      refresh_token: refreshToken,
    });
  }

  /** Revoke an access or refresh token (sign out). */
  revoke(token) {
    return this.#post("/revoke", { token });
  }

  /** Load the user's profile. */
  async userinfo(accessToken) {
    const res = await fetch(this.base + "/userinfo", {
      headers: { Authorization: `Bearer ${accessToken}` },
    });
    const data = await res.json().catch(() => ({}));
    if (!res.ok) throw new FadesError(res.status, data);
    return data;
  }
}

Methods

MethodReturnsCalls
createPkcePair(){ verifier, challenge }Local only
randomString(bytes)URL-safe stringLocal only. Use for state and nonce
authorizationUrl(opts)URL stringRedirect target for /authorize
exchangeCode({ code, verifier })Token responsePOST /token
refresh(refreshToken)Token responsePOST /token
userinfo(accessToken)Profile objectGET /userinfo
revoke(token){}POST /revoke
Handling errors: every failed call throws a FadesError with status, error (like "invalid_grant") and, for 500s, a requestId you can quote when asking for help. See Error codes.
Never ship clientSecret to a browser. If your code runs in a user’s browser, create a public app and leave the secret out.

Browser app (public client)

For a pure front-end app. Create a public app, then run the whole flow in the browser: PKCE protects the code exchange, so no secret is needed.

1. Start sign-in

import { FadesIdentity, createPkcePair, randomString } from "./fades-identity.js";

const fades = new FadesIdentity({
  clientId: "fades_YOUR_PUBLIC_APP_ID",
  redirectUri: "http://localhost:3000/callback", // must be registered on the app
});

document.querySelector("#login").addEventListener("click", async () => {
  const { verifier, challenge } = await createPkcePair();
  const state = randomString(16);
  const nonce = randomString(16);

  sessionStorage.setItem("fades_login", JSON.stringify({ verifier, state, nonce }));
  location.href = fades.authorizationUrl({ challenge, state, nonce });
});

2. Handle the callback

const params = new URLSearchParams(location.search);
const saved = JSON.parse(sessionStorage.getItem("fades_login") || "null");
sessionStorage.removeItem("fades_login");

if (params.get("error")) throw new Error(`Sign-in failed: ${params.get("error")}`);
if (!saved || params.get("state") !== saved.state) throw new Error("State mismatch");

const tokens = await fades.exchangeCode({
  code: params.get("code"),
  verifier: saved.verifier,
});
const user = await fades.userinfo(tokens.access_token);

history.replaceState({}, "", "/"); // remove ?code= from the address bar
console.log(`Signed in as @${user.preferred_username}`);
Keep tokens in memory when you can. Anything in localStorage is readable by any script on your page.

Next.js

Keep the client secret on the server. The browser sends only the code and the PKCE code_verifier to your own API route, and the route adds the secret and talks to Fades.

Environment variables

FADES_CLIENT_ID=fades_a1B2c3D4e5F6g7H8
FADES_CLIENT_SECRET=your-secret-here
FADES_REDIRECT_URI=https://example.com/auth/callback

App Router route

export const dynamic = "force-dynamic";

const ISSUER = (process.env.FADES_ISSUER || "https://api.fades.lol").replace(/\/+$/, "");

export async function POST(request) {
  const clientId = process.env.FADES_CLIENT_ID;
  const clientSecret = process.env.FADES_CLIENT_SECRET;
  const redirectUri = process.env.FADES_REDIRECT_URI;

  if (!clientId || !clientSecret || !redirectUri) {
    return Response.json({ error: "server_configuration_error" }, { status: 500 });
  }

  const { code, code_verifier } = await request.json().catch(() => ({}));
  if (typeof code !== "string" || typeof code_verifier !== "string") {
    return Response.json({ error: "invalid_request" }, { status: 400 });
  }

  try {
    const upstream = await fetch(`${ISSUER}/oauth/token`, {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      cache: "no-store",
      signal: AbortSignal.timeout(20000),
      body: JSON.stringify({
        grant_type: "authorization_code",
        client_id: clientId,
        client_secret: clientSecret,
        code,
        redirect_uri: redirectUri,
        code_verifier,
      }),
    });

    // Pass Fades's status and body straight through.
    const data = await upstream.json().catch(() => null);
    if (!data) return Response.json({ error: "invalid_upstream_response" }, { status: 502 });

    return Response.json(data, {
      status: upstream.status,
      headers: { "Cache-Control": "no-store" },
    });
  } catch {
    return Response.json({ error: "upstream_unavailable" }, { status: 502 });
  }
}

Calling it from the callback page

"use client";
import { useEffect } from "react";

export default function Callback() {
  useEffect(() => {
    const params = new URLSearchParams(location.search);
    const saved = JSON.parse(sessionStorage.getItem("fades_login") || "null");
    sessionStorage.removeItem("fades_login");

    if (!saved || params.get("state") !== saved.state) return; // reject

    fetch("/api/token", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ code: params.get("code"), code_verifier: saved.verifier }),
    })
      .then((res) => res.json())
      .then((tokens) => {
        // Use tokens.access_token to call /oauth/userinfo
      });
  }, []);

  return <p>Signing you in…</p>;
}
On the Pages Router, the same logic works as export default async function handler(req, res) in pages/api/token.js. Read req.body and reply with res.status(…).json(…).

Express

A complete server-side login using the JavaScript client and session storage. Install the one extra dependency with npm i express express-session.

import express from "express";
import session from "express-session";
import { FadesIdentity, createPkcePair, randomString } from "./fades-identity.js";

const app = express();

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production" },
}));

const fades = new FadesIdentity({
  clientId: process.env.FADES_CLIENT_ID,
  clientSecret: process.env.FADES_CLIENT_SECRET,
  redirectUri: "https://example.com/auth/callback",
});

// Step 1: send the user to Fades
app.get("/auth/login", async (req, res) => {
  const { verifier, challenge } = await createPkcePair();
  const state = randomString(16);
  const nonce = randomString(16);

  req.session.fades = { verifier, state, nonce };
  res.redirect(fades.authorizationUrl({ challenge, state, nonce }));
});

// Step 2: Fades sends them back here
app.get("/auth/callback", async (req, res) => {
  const saved = req.session.fades;
  delete req.session.fades;

  if (req.query.error) return res.status(400).send(`Sign-in failed: ${req.query.error}`);
  if (!saved || req.query.state !== saved.state) return res.status(400).send("Invalid state");

  try {
    const tokens = await fades.exchangeCode({ code: req.query.code, verifier: saved.verifier });
    const user = await fades.userinfo(tokens.access_token);

    req.session.user = { id: user.sub, username: user.preferred_username, email: user.email };
    req.session.refreshToken = tokens.refresh_token;
    res.redirect("/");
  } catch (error) {
    console.error("fades sign-in failed", error.status, error.error, error.requestId);
    res.status(502).send("Sign-in failed. Please try again.");
  }
});

// Sign out: also revoke at Fades
app.post("/auth/logout", async (req, res) => {
  const token = req.session.refreshToken;
  if (token) await fades.revoke(token).catch(() => {});
  req.session.destroy(() => res.redirect("/"));
});

app.listen(3000);

Python

A small module with the same surface as the JavaScript client. Needs requests (pip install requests).

"""fades_identity.py: minimal client for Fades Identity."""
import base64
import hashlib
import secrets
from urllib.parse import urlencode

import requests

ISSUER = "https://api.fades.lol"


class FadesError(Exception):
    def __init__(self, status, body):
        super().__init__(body.get("error_description") or body.get("error") or "Request failed")
        self.status = status
        self.error = body.get("error")
        self.request_id = body.get("request_id")


def random_string(n=32):
    return secrets.token_urlsafe(n)


def create_pkce_pair():
    verifier = secrets.token_urlsafe(32)  # 43 characters
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
    return verifier, challenge


class FadesIdentity:
    def __init__(self, client_id, redirect_uri, client_secret=None, issuer=ISSUER):
        self.client_id = client_id
        self.client_secret = client_secret
        self.redirect_uri = redirect_uri
        self.base = issuer.rstrip("/") + "/oauth"

    def authorization_url(self, challenge, state, nonce=None, scope="openid profile email"):
        params = {
            "client_id": self.client_id,
            "redirect_uri": self.redirect_uri,
            "response_type": "code",
            "scope": scope,
            "state": state,
            "code_challenge": challenge,
            "code_challenge_method": "S256",
        }
        if nonce:
            params["nonce"] = nonce
        return f"{self.base}/authorize?{urlencode(params)}"

    def _post(self, path, body):
        payload = {"client_id": self.client_id, **body}
        if self.client_secret:
            payload["client_secret"] = self.client_secret
        res = requests.post(self.base + path, json=payload, timeout=20)
        data = res.json() if res.content else {}
        if not res.ok:
            raise FadesError(res.status_code, data)
        return data

    def exchange_code(self, code, verifier):
        return self._post("/token", {
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": self.redirect_uri,
            "code_verifier": verifier,
        })

    def refresh(self, refresh_token):
        return self._post("/token", {"grant_type": "refresh_token", "refresh_token": refresh_token})

    def revoke(self, token):
        return self._post("/revoke", {"token": token})

    def userinfo(self, access_token):
        res = requests.get(
            self.base + "/userinfo",
            headers={"Authorization": f"Bearer {access_token}"},
            timeout=20,
        )
        data = res.json() if res.content else {}
        if not res.ok:
            raise FadesError(res.status_code, data)
        return data

Flask example

import os
from flask import Flask, redirect, request, session
from fades_identity import FadesIdentity, FadesError, create_pkce_pair, random_string

app = Flask(__name__)
app.secret_key = os.environ["SESSION_SECRET"]

fades = FadesIdentity(
    client_id=os.environ["FADES_CLIENT_ID"],
    client_secret=os.environ["FADES_CLIENT_SECRET"],
    redirect_uri="https://example.com/auth/callback",
)

@app.get("/auth/login")
def login():
    verifier, challenge = create_pkce_pair()
    session["fades"] = {"verifier": verifier, "state": random_string(16)}
    return redirect(fades.authorization_url(challenge, session["fades"]["state"]))

@app.get("/auth/callback")
def callback():
    saved = session.pop("fades", None)
    if request.args.get("error") or not saved or request.args.get("state") != saved["state"]:
        return "Sign-in failed", 400
    try:
        tokens = fades.exchange_code(request.args["code"], saved["verifier"])
        user = fades.userinfo(tokens["access_token"])
    except FadesError as e:
        app.logger.error("fades error %s %s %s", e.status, e.error, e.request_id)
        return "Sign-in failed", 502
    session["user"] = {"id": user["sub"], "username": user["preferred_username"]}
    return redirect("/")

cURL

Raw HTTP for testing from a terminal. Replace the placeholder values.

Exchange a code

curl -X POST https://api.fades.lol/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "client_id": "fades_a1B2c3D4e5F6g7H8",
    "client_secret": "YOUR_CLIENT_SECRET",
    "code": "CODE_FROM_REDIRECT",
    "redirect_uri": "https://example.com/auth/callback",
    "code_verifier": "YOUR_PKCE_VERIFIER"
  }'

Same call with HTTP Basic authentication

curl -X POST https://api.fades.lol/oauth/token \
  -u "fades_a1B2c3D4e5F6g7H8:YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "code": "CODE_FROM_REDIRECT",
    "redirect_uri": "https://example.com/auth/callback",
    "code_verifier": "YOUR_PKCE_VERIFIER"
  }'

Refresh, load profile, sign out

# Refresh (save the new refresh_token from the response)
curl -X POST https://api.fades.lol/oauth/token \
  -H "Content-Type: application/json" \
  -d '{"grant_type":"refresh_token","client_id":"fades_a1B2c3D4e5F6g7H8","client_secret":"YOUR_CLIENT_SECRET","refresh_token":"YOUR_REFRESH_TOKEN"}'

# Profile
curl https://api.fades.lol/oauth/userinfo -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

# Revoke
curl -X POST https://api.fades.lol/oauth/revoke \
  -H "Content-Type: application/json" \
  -d '{"client_id":"fades_a1B2c3D4e5F6g7H8","client_secret":"YOUR_CLIENT_SECRET","token":"YOUR_REFRESH_TOKEN"}'

Make a PKCE pair in the terminal

verifier=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
echo "verifier:  $verifier"
echo "challenge: $challenge"

Verify an ID token

Do this whenever you rely on the ID token directly. Check the signature against the JWKS, then the issuer, audience, expiry and nonce.

import { createRemoteJWKSet, jwtVerify } from "jose"; // npm i jose

const JWKS = createRemoteJWKSet(new URL("https://api.fades.lol/oauth/jwks"));

export async function verifyIdToken(idToken, { clientId, nonce }) {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer: "https://api.fades.lol",
    audience: clientId,
    algorithms: ["RS256"],
  });

  if (nonce && payload.nonce !== nonce) throw new Error("Nonce mismatch");
  return payload; // { sub, name, preferred_username, email, ... }
}
import jwt  # pip install "pyjwt[crypto]"

jwks = jwt.PyJWKClient("https://api.fades.lol/oauth/jwks")

def verify_id_token(id_token, client_id, nonce=None):
    key = jwks.get_signing_key_from_jwt(id_token).key
    claims = jwt.decode(
        id_token,
        key,
        algorithms=["RS256"],
        audience=client_id,
        issuer="https://api.fades.lol",
    )
    if nonce and claims.get("nonce") != nonce:
        raise ValueError("Nonce mismatch")
    return claims
Reference

Error codes

Errors come back as JSON with an error field. Token endpoint errors follow the OAuth 2.0 format. error_description is included only on non-production servers, so always switch on error and the HTTP status.

Token and revoke endpoints

HTTPerrorWhat it means and how to fix it
401invalid_clientClient authentication failed: no client_id, unknown app, or wrong client_secret. Check your credentials and that you aren’t sending a secret for a public app (or omitting it for a confidential one).
400invalid_grantThe code or refresh token can’t be used. See the causes below.
400unsupported_grant_typegrant_type was missing or not authorization_code / refresh_token.
429rate limitedToo many requests. Wait and retry with backoff. See Limits.
500server_errorAn unexpected problem on Fades’s side. The response includes a request_id. Quote it when reporting the issue. Safe to retry a sign-in after a moment.

Common causes of invalid_grant

SituationFix
Code already used, unknown, or from another environmentCodes are single use. Start sign-in again.
Code older than 60 secondsExchange the code immediately after the redirect.
Code was issued to a different appUse the same client_id for authorize and token.
redirect_uri differs from the one used in /authorizeSend the identical string, including trailing slash and query.
Verifier missing or not 43–128 chars of A–Z a–z 0–9 - . _ ~Generate it from 32 random bytes, base64url-encoded.
Verifier doesn’t match the challengeRe-create the pair, and store the same verifier that produced the challenge you sent.
Refresh token unknown, already used, expired (30 days) or from another appRefresh tokens rotate. Always save the newest one. If it’s gone, send the user through sign-in again.

Authorize redirect errors

Returned as ?error=…&state=… on your redirect URI.

errorCause
invalid_requestBad or missing PKCE challenge or method, or an oversized nonce
unsupported_response_typeresponse_type isn’t code
invalid_scopeUnknown scope requested
access_deniedThe user declined

If the app or redirect URI itself is wrong, there is no redirect to your app. The user sees an error page on auth.fades.lol saying Unknown app. or This redirect address isn't registered for the app.

UserInfo

HTTPerrorCause
401invalid_tokenToken missing, unknown, expired, lacking openid, or the user no longer exists. Refresh and retry once, otherwise sign in again.
500server_errorUnexpected server problem. Retry shortly.

App management and connected apps

These return { "success": false, "error": "…" } with a readable message.

HTTPMessageEndpoint
400Give your app a name.Create app
400Add 1 to 10 valid redirect URLs (HTTPS, or localhost for development).Create app
400You can have up to 20 apps.Create app
404App not found.Delete app
500Unable to create the app. / Unable to load your apps. / Unable to delete the app. / Unable to load connected apps. / Unable to disconnect the app.Matching endpoint

HTTP status summary

StatusMeaning here
200 / 201Success (201 when an app is created)
400Your request was malformed or the grant is unusable
401Client authentication or bearer token failed
404Resource not found or not yours
429Rate limited
500Server problem. Includes a request_id on the token endpoint

Limits & lifetimes

WhatLimit
/token, /userinfo, /revoke60 requests per minute
Create app, delete app, consent decision30 requests per 10 minutes
Apps per account20
Redirect URIs per app10
App name length60 characters
nonce length1024 characters
Authorization code lifetime60 seconds
Access token lifetime1 hour
Refresh token lifetime30 days (rotating)
ID token lifetime5 minutes

When you exceed a rate limit the request fails with 429. Back off (wait a few seconds, then double the delay each retry) instead of retrying in a tight loop.

Security checklist

  • Keep the secret server-side. Environment variables only. Never in front-end code, mobile apps or git.
  • Always use PKCE with a fresh verifier per sign-in attempt. Never reuse one.
  • Verify state on the callback, and clear it after use.
  • Send a nonce and verify it if you use the ID token.
  • Use sub as the user key, not the email or username, which can change.
  • Check email_verified before trusting an email address for account linking.
  • Store refresh tokens like passwords (encrypted, server-side) and replace them on every refresh.
  • Request only the scopes you need. Users see them on the consent screen.
  • Revoke on sign-out and when deleting an account.
  • Don’t log tokens, codes, verifiers or secrets. Log the request_id instead.
  • Register exact HTTPS redirect URIs. No wildcards. Use localhost only in development.

Troubleshooting

Sign-in lands on an error page at auth.fades.lol

Either the client_id is wrong, or the redirect_uri isn’t an exact match for one registered on the app. Compare them character by character (scheme, host, port, path, trailing slash).

invalid_grant right after a successful redirect

Most often the redirect_uri sent to /token differs from the one sent to /authorize, or the verifier you saved isn’t the one that produced the challenge. It can also mean the code was already used (a double-fired callback in React strict mode is a common cause) or took longer than 60 seconds.

invalid_client

The secret is wrong, or missing for a confidential app. If you rotated or lost it, delete the app and create a new one. Secrets can’t be recovered.

server_error with a request_id

The problem is on the Fades side, not in your request. Try again in a moment. If it persists, report the request_id so it can be found in the server logs.

Browser console shows CORS errors on /token

Call /token from your server or from your own API route (see Next.js). Browser-only apps should use a public app and the browser example.

UserInfo returns only sub

The user granted only openid. Request openid profile email in scope and have them sign in again.

Changelog

DateVersionChanges
Oct 9, 2026v1OAuth 2.0 Authorization Code + PKCE, OpenID Connect ID tokens (RS256), JWKS, UserInfo, token revocation, rotating refresh tokens, app management and connected-app endpoints.

Fades Identity documentation · Built with ♥ for developers