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-serverGetting credentials
Registration is self-serve — you create and manage your own apps, no waiting on the Jami team. Credentials live behind Developer Mode:
- 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.
- 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.
- 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_idimmediately (plus aclient_secretfor confidential clients).
- 1Developerhover ⓘ
Enable Developer Mode
Settings → Developer. A one-time identity check (KYC) runs on first use; once approved, the developer tools stay unlocked. - signed in2Developerhover ⓘ
Open the apps manager
JamiDev → Sign in with Jami, or Settings → OAuth — both render the same manager. - fill the form3Developerhover ⓘ
Create an app
Name, one or more redirect URIs, scopes, and the client type (confidential or public). - owner-scoped create4Jamihover ⓘ
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:
| What | Notes |
|---|---|
| App name | Shown on the consent screen |
| Redirect URI(s) | HTTPS, exact-match; localhost allowed for development. Add as many as you need. |
| Client type | Confidential (server, has a secret) or public (SPA/native, PKCE only) |
| Scopes | See Scopes below |
| Website URL | Optional, linked from the consent screen |
| Logo URL | Optional, 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/jamiEach 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.
| Action | Effect |
|---|---|
| Edit | Change the name, redirect URIs, scopes, website and logo. Grant types are recomputed from your scopes automatically (adding offline_access turns on refresh tokens). |
| Rotate secret | Issues a new client_secret and invalidates the old one immediately. Confidential clients only — public clients have no secret to rotate. |
| Disable / enable | A kill switch. Disabling blocks new authorizations and refreshes; already-issued access tokens stay valid until they expire (≤ 1 hour). |
| Delete | Removes 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 yourclient_id,redirect_uriand PKCE challenge. - Token endpoint (
/oauth2/token) — your backend swaps the code for anaccess_token, anid_token, and (withoffline_access) a rotatingrefresh_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.
- 1Your 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). - redirect to Jami2Jamihover ⓘ
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. - redirect back · ?code3Your apphover ⓘ
Receive the code
Verify state and iss, then take the single-use authorization code. A declined consent returns ?error=access_denied instead. - exchange code (+ secret)4Jamihover ⓘ
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. - tokens · verify at /jwks5Your 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=S2562. Jami redirects back
GET https://your-app.com/callback
?code=AUTHORIZATION_CODE
&state=RANDOM_PER_ATTEMPT
&iss=https://jami.bio/api/authVerify 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
| Symptom | Cause |
|---|---|
invalid_client | Wrong client_id/secret, or the client is disabled |
invalid_grant | Code already used, expired (10 min), or code_verifier mismatch |
invalid_request, missing PKCE | code_challenge omitted, or method isn't S256 |
redirect_uri_mismatch | Must match a registered URI exactly — scheme, path, trailing slash |
Refresh returns invalid_grant | Refresh tokens rotate; store the new one from every response |
Endpoints
| Purpose | Endpoint |
|---|---|
| Authorization | GET https://jami.bio/api/auth/oauth2/authorize |
| Token | POST https://jami.bio/api/auth/oauth2/token |
| UserInfo | GET https://jami.bio/api/auth/oauth2/userinfo |
| Introspection | POST https://jami.bio/api/auth/oauth2/introspect |
| Revocation | POST https://jami.bio/api/auth/oauth2/revoke |
| JWKS | GET 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.
| Scope | Grants | Shown to the user as |
|---|---|---|
openid | Required. Returns sub. | "Confirm your Jami identity" |
profile | name, picture, given_name, family_name, Jami handle | "View your name and profile picture" |
email | email, email_verified | "View your email address" |
offline_access | Issues 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
| Claim | Scope | Example |
|---|---|---|
sub | openid | 6712f3a... |
name | profile | Abebe Bikila |
picture | profile | https://…/avatar.png |
given_name, family_name | profile | |
https://jami.bio/handle | profile | abebe — the user's Jami URL handle |
email | email | abebe@example.com |
email_verified | email | true |
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
| Token | Lifetime |
|---|---|
| Access token | 1 hour |
| ID token | 10 hours |
| Refresh token | 30 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
| Endpoint | Limit |
|---|---|
/oauth2/token | 20 / minute |
/oauth2/authorize | 30 / minute |
/oauth2/introspect | 100 / minute |
/oauth2/revoke | 30 / minute |
/oauth2/userinfo | 60 / 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
| Code | Meaning | What to do |
|---|---|---|
invalid_client | Bad client_id/secret, or the client is disabled | Check credentials; contact Jami |
invalid_grant | Code expired, already used, or code_verifier mismatch | Restart the flow |
invalid_request | Malformed request, missing PKCE | Fix the request |
access_denied | User declined | Don't retry automatically |
invalid_scope | Scope not allowed for your client | Request only granted scopes |
Authorization codes are single-use and expire in 10 minutes.
Checklist before going live
-
stategenerated per attempt and verified on callback -
issverified on callback - PKCE
S256with a fresh verifier per attempt - ID token signature verified against JWKS, with
iss/aud/expall checked - Users keyed on
sub, not email - Granted
scopechecked rather than assumed - Rotated refresh tokens persisted
- Client secret stored in a secret manager, never in source or a frontend bundle
