Overview
With “Sign in with Ulifyi”, people sign in to your app with their Ulifyi account. Ulifyi uses OAuth 2.0 with the authorization code flow and PKCE. Your app never sees a password, only a token for exactly the permissions the person agreed to.
Every address is on https://auth.uli.fyi:
| Purpose | Method and path |
|---|---|
| Start the sign-in | GET /auth/identify/authorize |
| Exchange the code for tokens | POST /auth/identify/token |
| Refresh tokens | POST /auth/identify/refresh |
| Who is signed in? | GET /auth/identify/userinfo |
| Sign out (revoke a token) | POST /auth/identify/revoke |
| Every permission (scope) | GET /api/auth/identify/scopes |
The flow in short:
- Your app sends the browser to
authorizewith its client ID, redirect URI, scopes,stateand a PKCE challenge. - Ulifyi signs the person in (if needed) and shows the consent screen.
- After “Allow”, the browser comes back to your redirect URI with
?code=…&state=…. - Your server exchanges the code for an access token and a refresh token.
- With the access token you call
userinfoand, depending on the scopes, the Ulifyi Business API.
Create an app
You create apps in the developer console. All you need is a Ulifyi account.
- New app: name, support email and publisher. The publisher is you or a company you founded or where your role can open the developer settings. The publisher is shown on the consent screen.
- Client ID and client secret are shown. You see the secret this one time only. Ulifyi stores only a hash of it. If you lose it, create a new one in the console; the old one stops working at once.
- Add your redirect URIs (see Redirect URIs).
- Choose the permissions (see Permissions).
- Upload a logo and add your website, privacy policy and, optionally, terms of service.
Limits: at most 10 apps per account, 10 redirect URIs and 100 test users per app. Names containing “Ulifyi” are reserved for Ulifyi.
Quickstart
A complete example for Node.js 18 or later, without a library. The code exchange runs on your server so the client secret never reaches the browser.
import crypto from "node:crypto";
const AUTH = "https://auth.uli.fyi";
const CLIENT_ID = process.env.ULIFYI_CLIENT_ID;
const REDIRECT_URI = "https://your-app.com/auth/ulifyi/callback";
const base64url = (buf) => buf.toString("base64url");
export function startSignIn(session) {
const state = base64url(crypto.randomBytes(16));
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
session.ulifyi = { state, verifier };
const url = new URL("/auth/identify/authorize", AUTH);
url.search = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: "user user:email",
state,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString();
return url.toString(); // redirect there
}export async function finishSignIn(session, query) {
const saved = session.ulifyi;
delete session.ulifyi;
if (query.error) throw new Error(`Cancelled: ${query.error}`);
if (!saved || query.state !== saved.state) throw new Error("state does not match");
const res = await fetch(`${AUTH}/auth/identify/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
code: query.code,
redirect_uri: REDIRECT_URI,
code_verifier: saved.verifier,
}),
});
if (!res.ok) throw new Error((await res.json()).error);
return res.json(); // { access_token, refresh_token, … }
}export async function whoIsIt(accessToken) {
const res = await fetch(`${AUTH}/auth/identify/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error((await res.json()).error);
const { user } = await res.json();
return user; // { user_id, username, display_name, email, avatar }
}Authorization request
Send the browser (not a background request) to GET https://auth.uli.fyi/auth/identify/authorize:
| Parameter | Required | Meaning |
|---|---|---|
response_type | yes | Always code. |
client_id | yes | The client ID from the console. |
redirect_uri | yes | Must match a registered redirect URI character for character. |
scope | yes | Space-separated scopes, e.g. user user:email. |
state | yes | A random value, at least 8 characters. Comes back unchanged. |
code_challenge | recommended | PKCE: base64url of the SHA-256 of your code_verifier. Required for apps without a secret (mobile, desktop, single-page). |
code_challenge_method | no | Only S256 (the default). |
prompt | no | login or select_account: switch accounts. consent: always ask again. |
If the person already allowed exactly these scopes for your app, Ulifyi redirects back without the consent screen. Exceptions: prompt=consent, or company scopes (the company is chosen every time).
The browser comes back like this:
https://your-app.com/auth/ulifyi/callback?code=9b1c…4e&state=Zp81vQe0aBhttps://your-app.com/auth/ulifyi/callback?error=access_denied&state=Zp81vQe0aBAlways check first that state matches what you stored. Otherwise discard the response.
Exchange the code
POST https://auth.uli.fyi/auth/identify/token with application/x-www-form-urlencoded or JSON:
| Field | Meaning |
|---|---|
response_type | code |
client_id | your client ID |
code | the code from the redirect |
redirect_uri | the same redirect URI as in the request |
code_verifier | the PKCE verifier, or |
client_secret | your client secret (server side only) |
The code is valid for 5 minutes and once, even if the exchange fails.
{
"access_token": "eyJhbGciOiJSUzI1NiIs…",
"refresh_token": "eyJhbGciOiJSUzI1NiIs…",
"expires_in": 1791367200000,
"access_token_expires_in": 1791367200000,
"refresh_token_expires_in": 1793958300000
}The access token is a JWT (RS256). Treat it as opaque anyway and call userinfo instead of reading it yourself.
Get the user
GET https://auth.uli.fyi/auth/identify/userinfo with Authorization: Bearer <access_token>:
{
"user": {
"user_id": "214266353199973343",
"username": "max",
"display_name": "Max Muster",
"email": "[email protected]",
"avatar": "https://cdn.uli.fyi/avatars/214266353199973343/a1b2c3.png"
},
"scopes": ["user", "user:email"],
"organization": null
}emailis set only with theuser:emailscope, otherwisenull.organizationis set when the person chose a company while signing in (see Company permissions).- When the person changes their password, older tokens stop working (
401 password_changed). Sign them in again.
Refresh tokens
Before the access token expires, get a new pair with the refresh token:
curl -X POST https://auth.uli.fyi/auth/identify/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token":"eyJhbGciOiJSUzI1NiIs…"}'The response looks like the code exchange. Store the new refresh token and discard the old one. Ulifyi replaces refresh tokens on every use:
- If the old token is used again within 60 seconds (a parallel request, a retry), the same response comes back.
- If it is used again later, Ulifyi treats it as stolen, ends the whole sign-in and answers
401 token_replayed. 401 authorization_revokedmeans the person disconnected your app in their account. Sign them in again.
Sign out
When someone signs out of your app, end the connection to Ulifyi too:
curl -X POST https://auth.uli.fyi/auth/identify/revoke \
-H "Content-Type: application/json" \
-d '{"token":"<refresh_token>","client_id":"<your client ID>"}'The answer is always 200 {}, even for unknown tokens. The refresh token stops working. Access tokens already issued expire within 15 minutes.
Permissions
Every permission (scope) has a tier. It decides whether Ulifyi has to review your app before it may use the permission for everyone.
| Tier | Meaning |
|---|---|
| Basic | Works right away, even when published without a review. |
| Sensitive | Reads private data. Works at once in testing; once published, only after review. |
| Restricted | Acts for the person or changes company data. Works at once in testing; once published, only after review. |
| Scope | Tier | What the person allows |
|---|---|---|
user | Basic | See their name, username and profile picture |
user:email | Basic | See their email address |
user:posts | Sensitive | See their private posts |
user:friends | Sensitive | See their friends list |
user:posts:upload | Restricted | Create new posts |
user:posts:delete | Restricted | Delete posts |
user:posts:visibility | Restricted | Change who can see posts |
user:add:friend | Restricted | Add friends |
user:remove:friend | Restricted | Remove friends |
org | Basic | Know which company the app is used for |
org:view:members | Sensitive | See the company's members |
org:view:settings | Sensitive | See the company's settings |
org:view:forms | Sensitive | See forms and submissions |
org:view:finance | Sensitive | See the company's cash book |
pay:plan | Sensitive | See the company's plan and features through Ulifyi Pay |
org:manage:members | Restricted | Invite and remove members, change roles |
org:manage:settings:general | Restricted | Change the name, description and website |
org:manage:settings:branding | Restricted | Change the logo and icon |
org:manage:roles | Restricted | Manage roles and permissions |
org:manage:forms | Restricted | Update submissions |
org:manage:finance | Restricted | Manage income, expenses, accounts and receipts |
pay:subscriptions | Restricted | See subscriptions and terms through Ulifyi Pay |
Sub-scopes need their base scope: user:email needs user, every company scope needs org. The console adds them when you save, but your request has to send them too. Only ask for what your app really needs: the person sees every line on the consent screen.
GET https://auth.uli.fyi/api/auth/identify/scopes returns the current list as JSON, with texts in German and English.
Company permissions
With org and the org: or pay: scopes, your app works for a company on Ulifyi Business:
- On the consent screen the person picks one of their companies. Only companies where their role has the needed rights are offered, e.g. “view forms” for
org:view:forms. - The access token then works for exactly that company.
userinforeturns it underorganization, withoId, name, role and rights. - The rights are checked again on every call. If the person loses their role or leaves the company, calls fail and the refresh token ends.
With the access token you call the Ulifyi Business API, for example the company's forms:
curl https://pay.uli.fyi/v1/orgs/<oId>/forms \
-H "Authorization: Bearer <access_token>"Testing and publishing
Every new app starts in testing. Step by step, it becomes an app everyone can use:
| Stage | Who can sign in | Which scopes work |
|---|---|---|
| 1. Testing | Only you and your test users | Every scope you chose. The consent screen says “This app is still being tested”. |
| 2. Published | Every Ulifyi account | Basic scopes only. The consent screen warns: “Ulifyi has not reviewed this app”. |
| 3. Reviewed | Every Ulifyi account | Every scope Ulifyi approved. No warning, a check mark next to the app's name. |
Add test users in the console by email address or @username (up to 100). Anyone not on the list sees “Access blocked” and your app's support email while it is in testing.
You can publish once your support email, website, privacy policy and at least one redirect URI are filled in. If a published app asks for a scope Ulifyi has not approved yet, the sign-in is blocked: the person sees “Access blocked” and that Ulifyi has not reviewed this access yet.
Review by Ulifyi
For sensitive and restricted scopes, request a review in the console. In at least 30 characters, describe what your app needs the data for and where the person sees that in your app.
The Ulifyi team checks:
- that the website, name and logo belong to the publisher and cannot be mistaken for Ulifyi or other brands,
- that your privacy policy explains what happens to the Ulifyi data,
- that every requested scope is needed for the purpose you describe.
You see the result in the console. Ulifyi can approve all, some or none of the scopes and writes you a reason. You can request a new review at any time.
If your app is already reviewed and needs more later, it keeps everything already approved until the decision. If you change the name, website or privacy policy after the review, Ulifyi looks at the app again.
Consent screen
This is what the person sees before allowing your app anything:
- Your app's logo, name and publisher, with a check mark once reviewed.
- Every requested permission as its own line, in their language (German or English).
- For company scopes, the choice of company.
- Notices while the app is in testing or not reviewed.
- Links to your app's website, privacy policy and terms of service.
The person can disconnect your app at any time under “Connected apps” in their Ulifyi account.
Logo and branding
- Formats: PNG, JPEG, WebP or SVG, up to 5 MB.
- Ulifyi turns it into a square 512 × 512 PNG. Use a square design with some margin.
- The logo appears on the consent screen and under “Connected apps”.
- Don't use Ulifyi logos, the Ulifyi bear or names that sound like Ulifyi. Your app's name may not contain “Ulifyi”.
- For a sign-in button, use “Sign in with Ulifyi” (German: “Mit Ulifyi anmelden”).
Redirect URIs
Ulifyi only redirects to addresses you registered in the console and compares them character for character:
https://for websites, e.g.https://your-app.com/auth/ulifyi/callback.http://only forlocalhost,127.0.0.1and[::1]while developing.- Custom schemes for mobile and desktop apps with a dot in the name, e.g.
com.yourapp:/oauth. - Not allowed: fragments (
#…), credentials in the address and schemes likejavascript:,data:orfile:.
Errors
| Where | What you see | Meaning |
|---|---|---|
| Authorization | Error page 400 | Invalid request: a parameter is missing, redirect_uri is not registered, a scope is unknown or its base scope is missing. |
| Authorization | “Access blocked” page 403 | Testing and the person is not a test user, or a scope not approved in a published app. |
| Redirect | ?error=access_denied | The person clicked “Cancel”. |
| Token | 400 with error | The code expired or was used, a different redirect_uri, a wrong verifier or a wrong secret. |
| Refresh | 401 token_replayed | An old refresh token was used again: the sign-in has ended. |
| Refresh | 401 authorization_revoked | The person disconnected your app. |
| userinfo | 401 invalid_token | The token expired or is invalid: refresh it or sign in again. |
| any | 429 | Too many requests. Wait as many seconds as Retry-After says. |
Security
- Always check
stateand always use PKCE, even if your app has a secret. - The client secret belongs on your server only, never in a mobile app, a single-page app or a repository. If it leaked, create a new one in the console right away.
- Store refresh tokens safely (encrypted, server side or in the device's secure storage) and keep the new one after every refresh.
- Only ask for the scopes you need. Fewer scopes mean fewer questions on the consent screen and a faster review.
- Report security issues through a support request with the topic “Security”.
Something unclear or missing?
Send a request to support