This article applies to implementing authorization and authentication in your own apps (e.g. vibe coded apps) using Peliqan as the backend and using Peliqan OAuth2 for authorization and authentication of your app users.
Contact Peliqan support and request activation of OAuth Apps in your Peliqan account
Register an OAuth2 app in your Peliqan account: Settings → OAuth2 Apps, and select at least one scope
Public app (no client secret) — browser/mobile apps. Use authorization_code; it also gets a rotating refresh_token (no secret needed, PKCE plus rotation cover that).
Confidential app (has a client secret). Can additionally use client_credentials (server-to-server, no user), and must also send client_secret when refreshing a token.
PKCE (code_challenge / code_verifier, S256 only) is mandatory for authorization_code, there is no way to opt out (RFC 9700).
Always pass an explicit scope. An authorize request with no scope and no default_scopes configured on the app is rejected with invalid_scope.
Base URLs
Deployment
Base URL
EU (Peliqan-hosted)
https://app.eu.peliqan.io
US (Peliqan-hosted)
https://app.us.peliqan.io
On-prem / white-label
your own domain
All examples below use a BASE_URL constant — set it to whichever of these applies to you.
Endpoint reference
Purpose
Method + path
Notes
Authorize page (browser)
GET /oauth2/authorize
Send the user's browser here. Nuxt-hosted consent screen.
Token
POST /api/oauth2/token/
All 3 grant types. Rate-limited to 20 req/min per IP.
Revoke
POST /api/oauth2/revoke/
RFC 7009. Idempotent — always succeeds.
Introspect
POST /api/oauth2/introspect/
RFC 7662 — check if a token is still active.
UserInfo
GET /api/oauth2/userinfo/
Requires a profile:read-scoped access token, sent as Authorization: Bearer.
List scopes
GET /api/oauth2/scopes/
Every scope the server recognizes, grouped by category.
Discovery
GET /.well-known/oauth-authorization-server
RFC 8414 metadata document.
JWKS
GET /api/oauth2/jwks/
Public keys for verifying access token signatures.
Dynamic Client Registration
POST /api/oauth2/register/
RFC 7591 — programmatic app registration (advanced, most devs use the Settings UI instead).
1. Authorization code flow (user login)
This is "Login with Peliqan" — the user's browser visits Peliqan, logs in, approves your app, and gets redirected back to you with a code. PKCE requires a code_verifier you generate and keep secret, and a code_challenge (its SHA-256 hash) you send up front.
‣
TypeScript
Works in Node.js 18+, browsers, Bun, and Deno — uses only fetch and the global Web Crypto API (crypto.subtle), no packages.
‣
Python
Standard library only — urllib, hashlib, secrets, base64. No packages.
‣
Curl
PKCE has to be generated before you build the URL. Peliqan only accepts S256 (base64url(SHA-256(verifier)), no padding):
Send the user's browser to the authorize URL (this step can't be scripted — it needs a real login):
open "$BASE_URL/oauth2/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&scope=profile%3Aread&state=$STATE&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256"
After the user approves, Peliqan redirects to redirect_uri?code=...&state=.... Confirm state matches what you generated, then exchange the code:
Confidential apps also send "client_secret" in the same JSON body.
2. Refreshing a token
Peliqan rotates refresh tokens on every use — the old one is revoked, so always persist the new refresh_token from the response, not just the new access token. Both public and confidential apps get a refresh token; confidential apps must additionally authenticate with client_secret on every refresh — public apps don't send one.
Revoke, introspect, get the logged-in user's identity, list available scopes, and fetch the RFC 8414 discovery document.
‣
TypeScript
‣
Python
‣
Curl
Error handling
Every non-2xx response returns a machine-readable OAuth2 error code — branch on that field, not the message string.
error
Meaning
invalid_request
Missing/malformed parameter.
invalid_client
Unknown client_id, bad client_secret, or client_credentials requested on a public app.
invalid_redirect_uri
redirect_uri doesn't match any URI registered on the app.
invalid_grant
Code/refresh token expired, already used, or PKCE code_verifier mismatch — restart the flow.
invalid_scope
No scope requested and the app has no default_scopes.
unsupported_grant_type
grant_type isn't one of the three supported values.
access_denied
User declined the consent screen.
permission_denied
App-management endpoints only (not the token endpoint) — e.g. OAuth2 app management isn't enabled for the account.
server_error
Unexpected server-side failure.
‣
TypeScript
Wrap the raw fetch call once and throw a typed error carrying the error code, so every caller can branch on err.code instead of string-matching:
‣
Python
‣
curl
{
"error": "invalid_grant",
"detail": "Authorization code has expired or already been used."
}
Gotchas
💡
PKCE is not optional. Every authorization_code request needs code_challenge (S256) or the authorize call is rejected — there's no legacy non-PKCE path.
No scope is added automatically. You get exactly what's registered on the app plus whatever you explicitly request — nothing (including profile:read) is silently injected.
Refresh tokens rotate, for every client type. The old one is revoked the instant you use it — always persist the newrefresh_token from the response, or the next refresh will fail with invalid_grant. Public apps get refresh tokens too (PKCE + rotation stand in for a client secret); only confidential apps additionally need to send client_secret on refresh.
Token endpoint is rate-limited to 20 requests/minute per IP.
state verification is on you. The server doesn't store or check it — you must compare the state you generated against the one that comes back on the redirect, or you're open to CSRF.
On-prem/white-label: just point BASE_URL at your own domain — the flow is otherwise identical.
const BASE_URL = "https://app.eu.peliqan.io" // or your on-prem domain
const CLIENT_ID = "YOUR_CLIENT_ID"
const REDIRECT_URI = "https://yourapp.com/auth/callback"
// --- PKCE + state helpers (RFC 7636) ---
function base64UrlEncode(bytes: Uint8Array): string {
let binary = ""
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function generateCodeVerifier(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
return base64UrlEncode(new Uint8Array(digest))
}
function generateState(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)))
}
// 1. Build the authorize URL. Persist `state` + `codeVerifier` server-side
// (session/cookie, keyed by a per-flow id) — you need both in the callback.
const state = generateState()
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
const authorizeUrl = `${BASE_URL}/oauth2/authorize?` + new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: "profile:read",
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
})
// redirect the user's browser to `authorizeUrl`
// 2. On your callback route (GET /auth/callback?code=...&state=...):
// verify `state` matches what you persisted, then exchange the code.
// Confidential apps must also pass their clientSecret here.
async function exchangeCode(code: string, codeVerifier: string, clientSecret?: string) {
const response = await fetch(`${BASE_URL}/api/oauth2/token/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
code,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_verifier: codeVerifier,
...(clientSecret ? { client_secret: clientSecret } : {}),
}),
})
const data = await response.json()
if (!response.ok) throw new Error(`${data.error}: ${data.detail ?? ""}`)
return data // { access_token, refresh_token, expires_in, scope, token_type }
}