Skip to content
Ulifyi accountManage profile, password and devices
Create a Ulifyi account

Sign in with Ulifyi

The guide for developers: create an app, add the sign-in, choose permissions and publish the app.

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:

PurposeMethod and path
Start the sign-inGET /auth/identify/authorize
Exchange the code for tokensPOST /auth/identify/token
Refresh tokensPOST /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:

  1. Your app sends the browser to authorize with its client ID, redirect URI, scopes, state and a PKCE challenge.
  2. Ulifyi signs the person in (if needed) and shows the consent screen.
  3. After “Allow”, the browser comes back to your redirect URI with ?code=…&state=….
  4. Your server exchanges the code for an access token and a refresh token.
  5. With the access token you call userinfo and, 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.

  1. 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.
  2. 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.
  3. Add your redirect URIs (see Redirect URIs).
  4. Choose the permissions (see Permissions).
  5. 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.

1. Start the sign-in
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
}
2. Back at your redirect URI
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, … }
}
3. Get the user
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:

ParameterRequiredMeaning
response_typeyesAlways code.
client_idyesThe client ID from the console.
redirect_uriyesMust match a registered redirect URI character for character.
scopeyesSpace-separated scopes, e.g. user user:email.
stateyesA random value, at least 8 characters. Comes back unchanged.
code_challengerecommendedPKCE: base64url of the SHA-256 of your code_verifier. Required for apps without a secret (mobile, desktop, single-page).
code_challenge_methodnoOnly S256 (the default).
promptnologin 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:

Allowed
https://your-app.com/auth/ulifyi/callback?code=9b1c…4e&state=Zp81vQe0aB
Cancelled
https://your-app.com/auth/ulifyi/callback?error=access_denied&state=Zp81vQe0aB

Always 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:

FieldMeaning
response_typecode
client_idyour client ID
codethe code from the redirect
redirect_urithe same redirect URI as in the request
code_verifierthe PKCE verifier, or
client_secretyour client secret (server side only)

The code is valid for 5 minutes and once, even if the exchange fails.

Response
{
  "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>:

Response
{
  "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
}
  • email is set only with the user:email scope, otherwise null.
  • organization is 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:

Request
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_revoked means 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:

Request
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.

TierMeaning
BasicWorks right away, even when published without a review.
SensitiveReads private data. Works at once in testing; once published, only after review.
RestrictedActs for the person or changes company data. Works at once in testing; once published, only after review.
ScopeTierWhat the person allows
userBasicSee their name, username and profile picture
user:emailBasicSee their email address
user:postsSensitiveSee their private posts
user:friendsSensitiveSee their friends list
user:posts:uploadRestrictedCreate new posts
user:posts:deleteRestrictedDelete posts
user:posts:visibilityRestrictedChange who can see posts
user:add:friendRestrictedAdd friends
user:remove:friendRestrictedRemove friends
orgBasicKnow which company the app is used for
org:view:membersSensitiveSee the company's members
org:view:settingsSensitiveSee the company's settings
org:view:formsSensitiveSee forms and submissions
org:view:financeSensitiveSee the company's cash book
pay:planSensitiveSee the company's plan and features through Ulifyi Pay
org:manage:membersRestrictedInvite and remove members, change roles
org:manage:settings:generalRestrictedChange the name, description and website
org:manage:settings:brandingRestrictedChange the logo and icon
org:manage:rolesRestrictedManage roles and permissions
org:manage:formsRestrictedUpdate submissions
org:manage:financeRestrictedManage income, expenses, accounts and receipts
pay:subscriptionsRestrictedSee 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. userinfo returns it under organization, with oId, 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:

The company's forms (org:view: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:

StageWho can sign inWhich scopes work
1. TestingOnly you and your test usersEvery scope you chose. The consent screen says “This app is still being tested”.
2. PublishedEvery Ulifyi accountBasic scopes only. The consent screen warns: “Ulifyi has not reviewed this app”.
3. ReviewedEvery Ulifyi accountEvery 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.

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 for localhost, 127.0.0.1 and [::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 like javascript:, data: or file:.

Errors

WhereWhat you seeMeaning
AuthorizationError page 400Invalid request: a parameter is missing, redirect_uri is not registered, a scope is unknown or its base scope is missing.
Authorization“Access blocked” page 403Testing and the person is not a test user, or a scope not approved in a published app.
Redirect?error=access_deniedThe person clicked “Cancel”.
Token400 with errorThe code expired or was used, a different redirect_uri, a wrong verifier or a wrong secret.
Refresh401 token_replayedAn old refresh token was used again: the sign-in has ended.
Refresh401 authorization_revokedThe person disconnected your app.
userinfo401 invalid_tokenThe token expired or is invalid: refresh it or sign in again.
any429Too many requests. Wait as many seconds as Retry-After says.

Security

  • Always check state and 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