Build

End-user auth

Every app has its own users. They sign up with email and password, or with a 6 digit code sent by email. A session is a short lived JWT access token plus a refresh token that rotates on every use.

Sessions

All auth routes live under /v1/apps/{app}/auth and need the app's publishable key (or its secret key). Signup, login, verify, and refresh all return the same session shape:

{
  "user": {"id": "...", "email": "ada@example.com", "data": {"name": "Ada"}},
  "access_token": "...",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "..."
}

Email and password

Sign up. data is optional profile JSON you choose.

SESSION=$(curl -sS -X POST https://api.atberth.com/v1/apps/$APP/auth/signup \
  -H "Authorization: Bearer $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","password":"correct horse battery","data":{"name":"Ada"}}')
ACCESS_TOKEN=$(echo "$SESSION" | jq -er .access_token)
REFRESH_TOKEN=$(echo "$SESSION" | jq -er .refresh_token)
USER_ID=$(echo "$SESSION" | jq -er .user.id)
echo "$USER_ID"

Log in later with the same body minus data:

SESSION=$(curl -sS -X POST https://api.atberth.com/v1/apps/$APP/auth/login \
  -H "Authorization: Bearer $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","password":"correct horse battery"}')
ACCESS_TOKEN=$(echo "$SESSION" | jq -er .access_token)
REFRESH_TOKEN=$(echo "$SESSION" | jq -er .refresh_token)

The CLI can do both for testing: berth auth signup --app "$APP" --email ada@example.com --password '...' and berth auth login with the same flags.

Email code

No password at all: ask for a code, then trade it for a session. Asking creates the user if the email is new. Codes are valid for 10 minutes, one per minute per email, five attempts.

curl -sS -X POST https://api.atberth.com/v1/apps/$APP/auth/code \
  -H "Authorization: Bearer $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com"}'
curl -sS -X POST https://api.atberth.com/v1/apps/$APP/auth/verify \
  -H "Authorization: Bearer $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","code":"123456"}'

The signed-in user

Send the publishable key in apikey and the user token in Authorization. The token alone also works.

curl -sS https://api.atberth.com/v1/apps/$APP/auth/me \
  -H "apikey: $PUBLISHABLE_KEY" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Update the profile or the password:

curl -sS -X PATCH https://api.atberth.com/v1/apps/$APP/auth/me \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"data":{"name":"Ada Lovelace"}}'

JWT claims

Access tokens are HS256 JWTs that expire after an hour. Your server can read the claims, but should check a token by calling /auth/me, since Berth holds the signing secret.

ClaimValue
subthe user id
emailthe user's email
audthe app slug
issberth
sidthe session id, shared by the refresh chain
expexpiry, Unix seconds

Look at the claims of the token you have:

echo "$ACCESS_TOKEN" | cut -d. -f2 | tr '_-' '/+' | jq -R '@base64d | fromjson'

Refresh and rotation

When the access token is about to expire, trade the refresh token for a new session. Each refresh token works once: the response carries a new one.

OLD_REFRESH_TOKEN=$REFRESH_TOKEN
SESSION=$(curl -sS -X POST https://api.atberth.com/v1/apps/$APP/auth/refresh \
  -H "Authorization: Bearer $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"refresh_token\":\"$REFRESH_TOKEN\"}")
ACCESS_TOKEN=$(echo "$SESSION" | jq -er .access_token)
REFRESH_TOKEN=$(echo "$SESSION" | jq -er .refresh_token)

If an old refresh token is ever used again, Berth treats it as stolen and revokes the whole chain, including the newest tokens. This answers 401:

curl -sS -X POST https://api.atberth.com/v1/apps/$APP/auth/refresh \
  -H "Authorization: Bearer $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"refresh_token\":\"$OLD_REFRESH_TOKEN\"}"

After that the user signs in again. Store the refresh token in the Keychain or Keystore, and write the new one back every time you refresh.

SESSION=$(curl -sS -X POST https://api.atberth.com/v1/apps/$APP/auth/login \
  -H "Authorization: Bearer $PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","password":"correct horse battery"}')
ACCESS_TOKEN=$(echo "$SESSION" | jq -er .access_token)
REFRESH_TOKEN=$(echo "$SESSION" | jq -er .refresh_token)

Sign out

Ends this session. Send {"all": true} to end every session of the user.

curl -sS -X POST https://api.atberth.com/v1/apps/$APP/auth/logout \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"all":false}'

Managing users from your server

With the secret key you can list, create, ban, and delete users. Deleting a user also deletes the rows they own.

berth auth users list --app "$APP"
USER_ID=$(curl -sS -X POST https://api.atberth.com/v1/apps/$APP/auth/users \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"grace@example.com","password":"another long password"}' | jq -er .user.id)
berth auth users show --app "$APP" "$USER_ID"
curl -sS -X PATCH https://api.atberth.com/v1/apps/$APP/auth/users/$USER_ID \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"banned":true}'
berth auth users ban --app "$APP" "$USER_ID" --unban
berth auth users delete --app "$APP" "$USER_ID" --yes

Calling from a phone or browser

Ship the base URL and the publishable key. Nothing else. Then sign in and send the user token on each request.

JavaScript (fetch)

const BASE = "https://api.atberth.com/v1/apps/notes_app";
const KEY = "bpk_..."; // publishable key only
async function login(email, password) {
  const res = await fetch(`${BASE}/auth/login`, {
    method: "POST",
    headers: { "apikey": KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
  });
  if (!res.ok) throw new Error((await res.json()).message);
  return res.json(); // { user, access_token, refresh_token, ... }
}
async function myNotes(session) {
  const res = await fetch(`${BASE}/tables/notes/rows?order=created_at.desc`, {
    headers: { "apikey": KEY, "Authorization": `Bearer ${session.access_token}` },
  });
  return (await res.json()).rows;
}

Swift

struct Session: Decodable {
    let access_token: String
    let refresh_token: String
    let expires_in: Int
}
let base = URL(string: "https://api.atberth.com/v1/apps/notes_app")!
let publishableKey = "bpk_..."
func login(email: String, password: String) async throws -> Session {
    var req = URLRequest(url: base.appending(path: "auth/login"))
    req.httpMethod = "POST"
    req.setValue(publishableKey, forHTTPHeaderField: "apikey")
    req.setValue("application/json", forHTTPHeaderField: "Content-Type")
    req.httpBody = try JSONEncoder().encode(["email": email, "password": password])
    let (data, response) = try await URLSession.shared.data(for: req)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.userAuthenticationRequired) }
    return try JSONDecoder().decode(Session.self, from: data)
}
func notes(_ session: Session) async throws -> Data {
    var req = URLRequest(url: base.appending(path: "tables/notes/rows"))
    req.setValue(publishableKey, forHTTPHeaderField: "apikey")
    req.setValue("Bearer \(session.access_token)", forHTTPHeaderField: "Authorization")
    return try await URLSession.shared.data(for: req).0
}

Kotlin (OkHttp)

val base = "https://api.atberth.com/v1/apps/notes_app"
val publishableKey = "bpk_..."
val client = OkHttpClient()
val json = "application/json".toMediaType()
fun login(email: String, password: String): JSONObject {
    val body = JSONObject().put("email", email).put("password", password)
    val req = Request.Builder()
        .url("$base/auth/login")
        .header("apikey", publishableKey)
        .post(body.toString().toRequestBody(json))
        .build()
    client.newCall(req).execute().use { res ->
        check(res.isSuccessful) { "login failed: ${res.code}" }
        return JSONObject(res.body!!.string())
    }
}
fun notes(accessToken: String): String {
    val req = Request.Builder()
        .url("$base/tables/notes/rows")
        .header("apikey", publishableKey)
        .header("Authorization", "Bearer $accessToken")
        .build()
    client.newCall(req).execute().use { return it.body!!.string() }
}

Keep the refresh token in the iOS Keychain or Android Keystore backed storage, never in plain preferences.