JamiDev

Sign in with Jami

OAuth 2.1 / OpenID Connect — add a "Sign in with Jami" button to your app.

Jami is an OAuth 2.1 / OpenID Connect provider. Issuer: https://jami.bio/api/auth.

Prefer reading the discovery document at runtime over hardcoding endpoints:

GET https://jami.bio/api/auth/.well-known/openid-configuration
GET https://jami.bio/api/auth/.well-known/oauth-authorization-server

Getting credentials

Registration is self-serve — you create and manage your own apps, no waiting on the Jami team. Credentials live behind Developer Mode:

  1. Enable Developer Mode. In Jami, go to Settings → Developer and turn on Developer Mode. First-time access runs a one-off identity check (KYC); once you're approved the developer tools stay unlocked.
  2. Open the OAuth apps manager. Either JamiDev → Sign in with Jami, or Settings → OAuth — both render the same manager, so use whichever you have open.
  3. Create an app. Give it a name, add one or more redirect URIs, pick your scopes, and choose the client type. Hit create and Jami issues a client_id immediately (plus a client_secret for confidential clients).
Self-serve registrationDeveloperJami
  1. 1
    Developerhover ⓘ

    Enable Developer Mode

    Settings → Developer. A one-time identity check (KYC) runs on first use; once approved, the developer tools stay unlocked.
  2. signed in
    2
    Developerhover ⓘ

    Open the apps manager

    JamiDev → Sign in with Jami, or Settings → OAuth — both render the same manager.
  3. fill the form
    3
    Developerhover ⓘ

    Create an app

    Name, one or more redirect URIs, scopes, and the client type (confidential or public).
  4. owner-scoped create
    4
    Jamihover ⓘ

    Client provisioned

    Jami stamps you as the owner, stores the secret hashed, and returns the client_id — plus the secret, shown this one time.

You fill in:

WhatNotes
App nameShown on the consent screen
Redirect URI(s)HTTPS, exact-match; localhost allowed for development. Add as many as you need.
Client typeConfidential (server, has a secret) or public (SPA/native, PKCE only)
ScopesSee Scopes below
Website URLOptional, linked from the consent screen
Logo URLOptional, shown on the consent screen

Everything is editable after the fact — see Managing your apps.

Multiple redirect URIs

Yes — a single app can register as many redirect URIs as you need, and you can add or remove them at any time. This is the normal way to cover several environments or surfaces under one client_id, for example:

https://app.example.com/callback
https://staging.example.com/callback
http://localhost:3000/api/auth/callback/jami

Each authorization request must send a redirect_uri that exactly matches one of the registered entries — scheme, host, port, path and trailing slash all count. A value that isn't on the list is rejected with redirect_uri_mismatch, which is what stops an attacker from redirecting your users' codes somewhere you never approved.

The secret is shown once

A confidential client's secret is displayed a single time, right after you create it or rotate it. Jami stores only a hash and can't show it again — copy it into your secret manager immediately. Lost it? Rotate to get a fresh one (the old one stops working the instant you rotate).

Managing your apps

The same manager (JamiDev → Sign in with Jami, or Settings → OAuth) lets you maintain an app over its whole lifetime. Every action is owner-scoped — you can only touch apps you created.

ActionEffect
EditChange the name, redirect URIs, scopes, website and logo. Grant types are recomputed from your scopes automatically (adding offline_access turns on refresh tokens).
Rotate secretIssues a new client_secret and invalidates the old one immediately. Confidential clients only — public clients have no secret to rotate.
Disable / enableA kill switch. Disabling blocks new authorizations and refreshes; already-issued access tokens stay valid until they expire (≤ 1 hour).
DeleteRemoves the app permanently.

Rotate and disable take effect immediately

Rotating or disabling stops new tokens at once, but an access token already in a partner's hands remains valid until it expires because it's a self-verifying JWT. For an instant cut-off of a specific token, revoke it — see Revocation.

Architecture

Jami is a standards-compliant OAuth 2.1 / OpenID Connect provider. Your app never sees a Jami password — it hands the user to Jami, Jami authenticates them and asks their consent, and your app receives a short-lived authorization code it exchanges for tokens on its own backend.

The pieces:

  • Authorization endpoint (/oauth2/authorize) — where you send the user. Jami signs them in, shows the consent screen listing exactly the scopes you asked for, and redirects back with a single-use code bound to your client_id, redirect_uri and PKCE challenge.
  • Token endpoint (/oauth2/token) — your backend swaps the code for an access_token, an id_token, and (with offline_access) a rotating refresh_token. Confidential clients authenticate here with their secret; public clients rely on PKCE alone.
  • JWKS (/jwks) — the public keys you verify tokens against, offline, without calling Jami.
  • UserInfo / Introspection / Revocation — read the signed-in user, check whether a token is still live, or kill one.

Credentials themselves are owner-scoped: when you create an app Jami records you as its owner and stores the client secret hashed (it's shown to you once and never again). Every edit, rotation, disable and delete is checked against that ownership, so one developer can never see or alter another's apps.

The sign-in flow, end to endYour appJami
  1. 1
    Your apphover ⓘ

    Start the flow

    Redirect the user to /oauth2/authorize with your client_id, a registered redirect_uri, the scopes, a fresh state, and a PKCE code_challenge (S256).
  2. redirect to Jami
    2
    Jamihover ⓘ

    Authenticate & consent

    Jami signs the user in (if needed) and shows the consent screen for exactly the scopes you asked for. The user can decline individual scopes.
  3. redirect back · ?code
    3
    Your apphover ⓘ

    Receive the code

    Verify state and iss, then take the single-use authorization code. A declined consent returns ?error=access_denied instead.
  4. exchange code (+ secret)
    4
    Jamihover ⓘ

    Issue tokens

    The token endpoint validates the code, PKCE verifier and client, then returns access_token, id_token and — with offline_access — a rotating refresh_token.
  5. tokens · verify at /jwks
    5
    Your apphover ⓘ

    Trust the user

    Verify the id_token against JWKS (iss / aud / exp), key the user on sub, and persist rotated refresh tokens.

The rest of this page is the concrete wiring for that flow.

Wiring it up

better-auth (fastest path)

Register Jami as a generic OAuth provider. Redirect URI: https://<your-app>/api/auth/oauth2/callback/jami.

// auth.ts
import { betterAuth } from 'better-auth';
import { genericOAuth } from 'better-auth/plugins';

export const auth = betterAuth({
  plugins: [
    genericOAuth({
      config: [
        {
          providerId: 'jami',
          clientId: process.env.JAMI_CLIENT_ID!,
          clientSecret: process.env.JAMI_CLIENT_SECRET!,
          discoveryUrl: 'https://jami.bio/api/auth/.well-known/openid-configuration',
          scopes: ['openid', 'profile', 'email'],
          pkce: true, // required — Jami rejects non-PKCE authorization requests
        },
      ],
    }),
  ],
});
// client.ts
import { createAuthClient } from 'better-auth/client';
import { genericOAuthClient } from 'better-auth/client/plugins';

export const authClient = createAuthClient({ plugins: [genericOAuthClient()] });

await authClient.signIn.oauth2({ providerId: 'jami', callbackURL: '/dashboard' });

Keep clientSecret server-side only. pkce: true is not optional — Jami enforces PKCE (S256) for confidential clients too.

Direct OAuth

Three requests.

1. Send the user to Jami

GET https://jami.bio/api/auth/oauth2/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fyour-app.com%2Fcallback
  &scope=openid%20profile%20email
  &state=RANDOM_PER_ATTEMPT
  &code_challenge=BASE64URL(SHA256(verifier))
  &code_challenge_method=S256

2. Jami redirects back

GET https://your-app.com/callback
  ?code=AUTHORIZATION_CODE
  &state=RANDOM_PER_ATTEMPT
  &iss=https://jami.bio/api/auth

Verify state matches and iss is exactly https://jami.bio/api/auth before continuing. A declined consent returns ?error=access_denied instead.

3. Exchange the code

curl -X POST https://jami.bio/api/auth/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=AUTHORIZATION_CODE \
  -d redirect_uri=https://your-app.com/callback \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET \
  -d code_verifier=ORIGINAL_VERIFIER
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile email",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "..."
}

refresh_token is present only if offline_access was granted. Decode id_token (after verifying it — see below) to identify the user, or call /oauth2/userinfo:

curl https://jami.bio/api/auth/oauth2/userinfo -H "Authorization: Bearer ACCESS_TOKEN"
{
  "sub": "69a809e5f8d34b98fe901a90",
  "name": "Abebe Bikila",
  "picture": "https://…/avatar.png",
  "email": "abebe@example.com",
  "email_verified": true,
  "https://jami.bio/handle": "abebe"
}

Store the user against sub — it's the only identifier guaranteed stable. Email, name and handle can all change.

The exchange above is a confidential client. Public clients — mobile apps, SPAs, anything that can't keep a secret — omit client_secret entirely and authenticate with PKCE alone. Confidential clients may instead send their credentials as HTTP Basic (client_secret_basic) rather than in the form body.

Common setup mistakes

SymptomCause
invalid_clientWrong client_id/secret, or the client is disabled
invalid_grantCode already used, expired (10 min), or code_verifier mismatch
invalid_request, missing PKCEcode_challenge omitted, or method isn't S256
redirect_uri_mismatchMust match a registered URI exactly — scheme, path, trailing slash
Refresh returns invalid_grantRefresh tokens rotate; store the new one from every response

Endpoints

PurposeEndpoint
AuthorizationGET https://jami.bio/api/auth/oauth2/authorize
TokenPOST https://jami.bio/api/auth/oauth2/token
UserInfoGET https://jami.bio/api/auth/oauth2/userinfo
IntrospectionPOST https://jami.bio/api/auth/oauth2/introspect
RevocationPOST https://jami.bio/api/auth/oauth2/revoke
JWKSGET https://jami.bio/api/auth/jwks

Scopes

v1 is identity only — you can authenticate a Jami user, but not read or write their Jami data. Resource scopes land later without breaking these.

ScopeGrantsShown to the user as
openidRequired. Returns sub."Confirm your Jami identity"
profilename, picture, given_name, family_name, Jami handle"View your name and profile picture"
emailemail, email_verified"View your email address"
offline_accessIssues a refresh token"Stay signed in"

Users can decline individual scopes on the consent screen — check the scope value returned with the token rather than assuming you got everything you asked for.

PKCE is mandatory

PKCE is required for all clients, confidential ones included, and only S256 is accepted (plain is rejected). Generate a fresh code_verifier (43–128 chars) and state per attempt, store both against the user's session, and on callback confirm state (CSRF) and iss (mix-up attack defence) before exchanging the code.

Validate the id_token JWT before trusting it: verify the signature against /jwks (cache the keys — they rotate), iss == https://jami.bio/api/auth, aud == your client_id, exp is in the future, and nonce matches if you sent one. Never trust an unverified sub.

Claims

ClaimScopeExample
subopenid6712f3a...
nameprofileAbebe Bikila
pictureprofilehttps://…/avatar.png
given_name, family_nameprofile
https://jami.bio/handleprofileabebe — the user's Jami URL handle
emailemailabebe@example.com
email_verifiedemailtrue

Custom Jami claims are namespaced with a URI so they never collide with a future standard OIDC claim. /oauth2/userinfo returns the same claims, limited to the granted scopes.

Token lifetimes and refresh

TokenLifetime
Access token1 hour
ID token10 hours
Refresh token30 days

Refresh tokens are issued only when offline_access was granted, and are rotated on every use — store the new one and discard the old. Reusing a consumed refresh token is treated as a compromise signal.

POST https://jami.bio/api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=<refresh_token>
&client_id=<client_id>
&client_secret=<client_secret>

Revocation

Users can disconnect your app anytime from Jami → Settings → Connected apps — refresh tokens stop working immediately. Access tokens are JWTs verified offline, so an already-issued one stays valid until it expires (max 1 hour). For a security-sensitive action, call introspection instead:

POST https://jami.bio/api/auth/oauth2/introspect
Content-Type: application/x-www-form-urlencoded

token=<access_token>&client_id=<id>&client_secret=<secret>

Returns {"active": false} for revoked, expired, or unknown tokens. To revoke a token yourself (e.g. on user logout), POST the same shape to /oauth2/revoke.

Rate limits

EndpointLimit
/oauth2/token20 / minute
/oauth2/authorize30 / minute
/oauth2/introspect100 / minute
/oauth2/revoke30 / minute
/oauth2/userinfo60 / minute

Exceeding a limit returns 429 — back off exponentially. Prefer verifying access-token JWTs locally against /jwks over calling introspection on every request; it's faster and doesn't consume the limit.

Errors

CodeMeaningWhat to do
invalid_clientBad client_id/secret, or the client is disabledCheck credentials; contact Jami
invalid_grantCode expired, already used, or code_verifier mismatchRestart the flow
invalid_requestMalformed request, missing PKCEFix the request
access_deniedUser declinedDon't retry automatically
invalid_scopeScope not allowed for your clientRequest only granted scopes

Authorization codes are single-use and expire in 10 minutes.

Checklist before going live

  • state generated per attempt and verified on callback
  • iss verified on callback
  • PKCE S256 with a fresh verifier per attempt
  • ID token signature verified against JWKS, with iss/aud/exp all checked
  • Users keyed on sub, not email
  • Granted scope checked rather than assumed
  • Rotated refresh tokens persisted
  • Client secret stored in a secret manager, never in source or a frontend bundle

On this page