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.
Base URLs
Everything your app calls lives under one base URL. Users approve access on the Fades sign-in site.
| Name | URL | Used for |
|---|---|---|
| API base | https://api.fades.lol/oauth | Token, UserInfo, revoke, JWKS, app management |
| Issuer | https://api.fades.lol | The iss value inside ID tokens |
| Sign-in site | https://auth.fades.lol | Where users sign in and approve your app |
openid, profile and email. Nothing else to configure.Quickstart
From zero to a signed-in user in about five steps.
- Register your app. Create an app to get a
client_id(and aclient_secretif it’s a server-side app). Add your callback URL as a redirect URI. See Registering an app. - Send the user to Fades. Generate a PKCE verifier and challenge, then redirect the browser to
/oauth/authorize. - 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. - Exchange the code. POST the code and your PKCE verifier to
/oauth/tokenfrom your server. You get an access token, a refresh token and an ID token. - Load the profile. Call
/oauth/userinfowith 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);
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.
| Type | Has a secret? | Use it for |
|---|---|---|
| Confidential | Yes. Shown once at creation | Server-side apps: Next.js API routes, Express, Python backends. The secret never leaves your server. |
| Public | No. Relies on PKCE | Single-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.
| Scope | Grants | Claims returned |
|---|---|---|
openid | Required for sign-in. Enables the ID token and UserInfo | sub |
profile | Username, display name, avatar | name, preferred_username, picture |
email | Email address and verification status | email, 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 sendcode_challenge_method=S256.plainis not supported.
Authorization flow
The Authorization Code flow with PKCE, step by step.
- Your app creates
verifier,challenge,state(and optionallynonce) and stores them where the callback can find them. - Browser → Fades
GET /oauth/authorize?…Fades validates the app and redirect URI, then forwards the user toauth.fades.lol. - User signs in and approves or denies the requested scopes.
- Fades → your callback The browser lands on your redirect URI with
codeandstate(orerrorandstate). - Your server → Fades
POST /oauth/tokenwith the code, redirect URI andcode_verifier. - Fades → your server returns
access_token,refresh_tokenand, foropenid, anid_token. - Your server → Fades
GET /oauth/userinfowith the access token to load the profile.
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.redirect_uri you send to /authorize and /token must be identical, character for character, to one registered on the app, including trailing slashes.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.
Redirect URI rules
- 1 to 10 redirect URIs per app, each an absolute URL.
https://is required.http://is allowed only forlocalhostand127.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.
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
| Claim | Always? | Description |
|---|---|---|
iss | Yes | Issuer: https://api.fades.lol |
sub | Yes | The user’s stable, unique ID. Use this as your user key |
aud | Yes | Your client_id |
iat, exp | Yes | Issued-at and expiry, in Unix seconds |
nonce | If you sent one | The nonce from your authorize request |
name | profile | Display name (falls back to the username) |
preferred_username | profile | The user’s Fades username |
picture | profile, if set | Avatar URL |
email | email, if set | Email address |
email_verified | email | true 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.
grant_type=refresh_token) does not return a new ID token. Use UserInfo for fresh profile data.Tokens & refresh
| Token | Lifetime | Notes |
|---|---|---|
| Authorization code | 60 seconds | Single use. Consumed the moment you redeem it |
| Access token | 1 hour (expires_in: 3600) | Opaque string. Send as Authorization: Bearer … |
| Refresh token | 30 days | Opaque. Rotates: every refresh returns a new one and kills the old one |
| ID token | 5 minutes | RS256 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.
/oauth/.well-known/openid-configurationDiscovery
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.
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"]
}
/oauth/jwksJWKS
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.
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"]
}
]
}
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.
/oauth/authorizeAuthorize
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.
Query parameters
| Name | Description |
|---|---|
client_idrequired | Your app’s ID, for example fades_a1B2c3D4e5F6g7H8 |
redirect_urirequired | One of the redirect URIs registered on the app. Exact match |
response_typerequired | Must be code |
code_challengerequired | base64url(SHA-256(verifier)), 43–128 chars of A–Z a–z 0–9 - _ |
code_challenge_methodrequired | Must be S256 |
scopeoptional | Space-separated list from openid profile email. Defaults to openid |
staterecommended | Random value you generate. Returned unchanged on the redirect back. Use it to block CSRF |
nonceoptional | Random 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
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
error | Cause |
|---|---|
unsupported_response_type | response_type wasn’t code |
invalid_request | Missing or malformed code_challenge, method isn’t S256, or nonce is too long or not a string |
invalid_scope | A scope other than openid, profile or email |
access_denied | The user pressed “Deny” |
/oauth/tokenToken · 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.
Client authentication
Pick one method. Public apps (no secret) send only client_id.
| Method | How |
|---|---|
client_secret_post | Put client_id and client_secret in the request body |
client_secret_basic | Authorization: Basic base64(urlencode(client_id):urlencode(client_secret)) |
none | Public apps only: client_id in the body, PKCE does the rest |
Body parameters
| Name | Description |
|---|---|
grant_typerequired | authorization_code |
coderequired | The code from the redirect |
redirect_urirequired | Same value you sent to /authorize |
code_verifierrequired | The original PKCE verifier (43–128 chars of A–Z a–z 0–9 - . _ ~) |
client_idrequired | Your app’s ID (unless using Basic auth) |
client_secretconfidential only | Your 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).
/oauth/tokenToken · 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.
Body parameters
| Name | Description |
|---|---|
grant_typerequired | refresh_token |
refresh_tokenrequired | The refresh token you were given |
client_id, client_secret | Same 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.
/oauth/userinfoUserInfo
Returns the signed-in user’s profile. Which fields appear depends on the scopes the user granted. Requires the openid scope.
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
}
| Field | Needs scope | Notes |
|---|---|---|
sub | openid | Always present. Use this as the user’s ID in your database |
name, preferred_username | profile | name falls back to the username if no display name is set |
picture | profile | Omitted when the user has no avatar |
email | email | Omitted when the account has no email |
email_verified | email | Boolean |
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.
/oauth/revokeRevoke
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.
| Name | Description |
|---|---|
tokenrequired | An access token or refresh token that belongs to your app |
client_id, client_secret | Same 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).
/oauth/clientsCreate 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.
| Field | Description |
|---|---|
namerequired | Shown to users on the consent screen. Trimmed to 60 characters |
redirectUrisrequired | Array of 1–10 URLs. HTTPS, or http://localhost / http://127.0.0.1 for development |
publicoptional | true 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 message | Cause |
|---|---|
| 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 |
/oauth/clientsList your apps
Returns the apps owned by the signed-in user. Secrets are never returned.
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"
}
]
}
/oauth/clients/:idDelete 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.
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.
/oauth/grantsList 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.
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"
}
]
}
/oauth/grants/:clientIdDisconnect 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.
await fetch("https://api.fades.lol/oauth/grants/fades_a1B2c3D4e5F6g7H8", {
method: "DELETE",
credentials: "include",
});
Response · 200
{ "success": true }
/oauth/authorize/info POST/oauth/authorize/decisionConsent endpoints
These two endpoints power the Fades consent page at auth.fades.lol. Your app doesn’t call them. They’re documented so you know what the user’s approval step does.
/authorize/infotakes the same fields as/authorizeand returns the app name, requested scopes and the signed-in user, so the page can render “App wants access to…”./authorize/decisiontakes the same fields plusapprove: true | false. On approval it creates a one-time code (valid 60 seconds) and returns{ "success": true, "redirectTo": "…?code=…&state=…" }. On denial it returns aredirectTowitherror=access_denied.
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
| Method | Returns | Calls |
|---|---|---|
createPkcePair() | { verifier, challenge } | Local only |
randomString(bytes) | URL-safe string | Local only. Use for state and nonce |
authorizationUrl(opts) | URL string | Redirect target for /authorize |
exchangeCode({ code, verifier }) | Token response | POST /token |
refresh(refreshToken) | Token response | POST /token |
userinfo(accessToken) | Profile object | GET /userinfo |
revoke(token) | {} | POST /revoke |
FadesError with status, error (like "invalid_grant") and, for 500s, a requestId you can quote when asking for help. See Error codes.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}`);
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>;
}
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
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
| HTTP | error | What it means and how to fix it |
|---|---|---|
| 401 | invalid_client | Client 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). |
| 400 | invalid_grant | The code or refresh token can’t be used. See the causes below. |
| 400 | unsupported_grant_type | grant_type was missing or not authorization_code / refresh_token. |
| 429 | rate limited | Too many requests. Wait and retry with backoff. See Limits. |
| 500 | server_error | An 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
| Situation | Fix |
|---|---|
| Code already used, unknown, or from another environment | Codes are single use. Start sign-in again. |
| Code older than 60 seconds | Exchange the code immediately after the redirect. |
| Code was issued to a different app | Use the same client_id for authorize and token. |
redirect_uri differs from the one used in /authorize | Send 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 challenge | Re-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 app | Refresh 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.
error | Cause |
|---|---|
invalid_request | Bad or missing PKCE challenge or method, or an oversized nonce |
unsupported_response_type | response_type isn’t code |
invalid_scope | Unknown scope requested |
access_denied | The 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
| HTTP | error | Cause |
|---|---|---|
| 401 | invalid_token | Token missing, unknown, expired, lacking openid, or the user no longer exists. Refresh and retry once, otherwise sign in again. |
| 500 | server_error | Unexpected server problem. Retry shortly. |
App management and connected apps
These return { "success": false, "error": "…" } with a readable message.
| HTTP | Message | Endpoint |
|---|---|---|
| 400 | Give your app a name. | Create app |
| 400 | Add 1 to 10 valid redirect URLs (HTTPS, or localhost for development). | Create app |
| 400 | You can have up to 20 apps. | Create app |
| 404 | App not found. | Delete app |
| 500 | Unable 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
| Status | Meaning here |
|---|---|
200 / 201 | Success (201 when an app is created) |
400 | Your request was malformed or the grant is unusable |
401 | Client authentication or bearer token failed |
404 | Resource not found or not yours |
429 | Rate limited |
500 | Server problem. Includes a request_id on the token endpoint |
Limits & lifetimes
| What | Limit |
|---|---|
/token, /userinfo, /revoke | 60 requests per minute |
| Create app, delete app, consent decision | 30 requests per 10 minutes |
| Apps per account | 20 |
| Redirect URIs per app | 10 |
| App name length | 60 characters |
nonce length | 1024 characters |
| Authorization code lifetime | 60 seconds |
| Access token lifetime | 1 hour |
| Refresh token lifetime | 30 days (rotating) |
| ID token lifetime | 5 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
stateon the callback, and clear it after use. - Send a
nonceand verify it if you use the ID token. - Use
subas the user key, not the email or username, which can change. - Check
email_verifiedbefore 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_idinstead. - Register exact HTTPS redirect URIs. No wildcards. Use
localhostonly 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
| Date | Version | Changes |
|---|---|---|
| Oct 9, 2026 | v1 | OAuth 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