# Overview > What authbase is, how it fits next to your backend, and where to start reading. authbase is an authentication service you run yourself. Run one instance, create an **app** for each product you build, and let each product's backend register and authenticate its **users** through authbase. Think of it as a smaller, self-hostable Auth0 or Firebase Auth. ``` end-user ──▶ your backend ──(API key)──▶ authbase ──▶ PostgreSQL │ │ └── verifies tokens ◀── JWKS (public keys, cached) ``` Your backend calls authbase with an API key for four things: sign-up, login, refresh and logout. Every other request is checked **locally**: the access token is a standard JWT that your backend verifies against the app's public keys, so authbase is never on your hot path. ## What you get - **The end-user API**: registration, login by email or username, refresh with reuse detection, logout and "sign out everywhere", roles and metadata, password reset and email verification with hosted pages. - **A dashboard** at the instance's root: apps, API and signing keys, users, settings, the audit log and your own account. - **SDKs** for Node ([`@authbase/node`](https://authbase.burakmetehan.com.tr/docs/sdk/node.md): Express, Hono, Next.js) and Kotlin/JVM ([Ktor and Spring Boot](https://authbase.burakmetehan.com.tr/docs/sdk/kotlin.md)). Anything else needs only JSON over HTTP and a JWT library ([integration guide](https://authbase.burakmetehan.com.tr/docs/integration.md)). - **Standards**: Ed25519 (`EdDSA`) access tokens per RFC 9068, a JWKS and OIDC discovery document per app, RFC 9457 problem details with stable error codes, an [OpenAPI spec](https://authbase.burakmetehan.com.tr/openapi.yaml). - **Protection that is always on**: Argon2id, a breached-password list, per-IP and per-key rate limits, lockout, an audit log, signing and API key rotation without downtime. - **One binary, one PostgreSQL, one master key** to operate. ## Where to start | You want to… | Read | |---|---| | see it work on your machine in under half an hour | [Quick start](https://authbase.burakmetehan.com.tr/docs/quickstart.md) | | understand apps, keys and tokens first | [Concepts](https://authbase.burakmetehan.com.tr/docs/concepts.md) | | add it to a Node or Kotlin backend | [Node SDK](https://authbase.burakmetehan.com.tr/docs/sdk/node.md), [Kotlin SDK](https://authbase.burakmetehan.com.tr/docs/sdk/kotlin.md) | | add it to anything else | [Integration guide](https://authbase.burakmetehan.com.tr/docs/integration.md) | | look up an endpoint or error code | [API reference](https://authbase.burakmetehan.com.tr/api/) | | run it in production | [Self-hosting](https://authbase.burakmetehan.com.tr/docs/self-hosting.md), [Security model](https://authbase.burakmetehan.com.tr/docs/security-model.md) | | have a coding agent do the integration | [Docs for agents](https://authbase.burakmetehan.com.tr/docs/agents.md), [Claude skills](https://authbase.burakmetehan.com.tr/docs/skills.md) | ## Status authbase is **in active development**. The API, email flows, dashboard and SDKs are complete, and it runs in production for its author. The source code, the container image and the SDK packages are not public yet: they come with the first public release, together with an independent security review and signed builds. Until then, this site introduces the project and documents how it works, and its install steps describe the release. Not there yet: teams that share apps, a hosted login page (OAuth 2.0 code flow with PKCE), multi-factor authentication, social login and webhooks. # Quick start > Run authbase and an example backend on your machine, then move the integration into your own. From nothing to a backend that signs users up, logs them in and protects its routes with authbase, on your own machine. Plan on well under half an hour. You need Docker with Compose v2, and one of Node 20.19+, JDK 21 or Go 1.27 for the example backend. Concepts first? [Concepts](https://authbase.burakmetehan.com.tr/docs/concepts.md) explains apps, API keys and tokens in two pages. ## 1. Start authbase authbase cannot be downloaded yet: the source code and the container image are published with the first public release. Until then, these steps describe a run from a copy of the source. ```sh cp deploy/.env.example deploy/.env # the two required secrets, generated into deploy/.env (gitignored) sed -i.bak "s|^AUTHBASE_MASTER_KEY=.*|AUTHBASE_MASTER_KEY=$(openssl rand -base64 32)|; s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 24)|" deploy/.env && rm deploy/.env.bak docker compose -f deploy/compose.yaml up -d --build curl -s localhost:8080/readyz # {"checks":{"database":"ok","migrations":"ok"},"status":"ok"} ``` The first `up` builds the image from source (a few minutes). Keep a copy of `AUTHBASE_MASTER_KEY` somewhere safe: without it the signing keys in the database cannot be opened ([Self-hosting: backups](https://authbase.burakmetehan.com.tr/docs/self-hosting.md#backups)). ## 2. Create an app and an API key Open http://localhost:8080 and: 1. **Sign up** with an email and a password of 12+ characters. 2. **New app**: a name, e.g. *Cashmate*; the slug (`cashmate`) is permanent and becomes part of your token issuer. 3. **API keys → Create API key**, named e.g. `development`. The key (`sk_live_…`) is shown **once**: copy it now. Prefer the terminal? The same, with the CLI inside the container: ```sh dc="docker compose -f deploy/compose.yaml exec authbase /authbase admin" $dc create-account --email you@example.com # prompts for a password $dc create-app --owner you@example.com --slug cashmate --name Cashmate $dc create-api-key --app cashmate --name development # prints the key once ``` ## 3. Put the key in your environment The key is a server secret: environment only, never in code or a committed file. `read -s` keeps it out of your shell history: ```sh export AUTHBASE_URL=http://localhost:8080 read -rs AUTHBASE_API_KEY && export AUTHBASE_API_KEY # paste the key, then Enter ``` ## 4. Run an example backend Each one serves the same routes ([Example backends](https://authbase.burakmetehan.com.tr/docs/examples.md)). Pick yours: ```sh # Node (Express): the SDK is used from source, so build it first (cd sdk/typescript && npm ci && npm run build) cd examples/express && npm install && npm start # :3001 # Kotlin (Ktor) # :3002 cd examples/ktor && ./gradlew run # Kotlin (Spring Boot) # :3003 cd examples/spring && ./gradlew bootRun # Go, no SDK # :3004 cd examples/go && go run . ``` ## 5. Try it ```sh APP=http://localhost:3001 curl -s -X POST $APP/signup -H 'Content-Type: application/json' \ -d '{"email":"ada@example.com","password":"correct horse battery staple"}' | tee /tmp/ada.json TOKEN=$(jq -r .access_token /tmp/ada.json) curl -s $APP/me -H "Authorization: Bearer $TOKEN" # {"id":"…","email":"ada@example.com","roles":[]} curl -si $APP/admin -H "Authorization: Bearer $TOKEN" | head -1 # 403: Ada is not an admin ``` `/me` never calls authbase: the example verified the token against your app's public keys, fetched once and cached. Ada now shows up in the dashboard under **Users**, where you can give her the `admin` role; her next login (or refresh) carries it. `examples/smoke.sh $APP` runs the whole flow, refresh and logout included, and is what CI runs against every example. ## 6. Into your own backend 1. Install the SDK. Nothing is published yet, so from this checkout: - Node: `cd sdk/typescript && npm ci && npm run build && npm pack`, then `npm install /path/to/authbase-node-0.1.0.tgz` in your project ([Node SDK](https://authbase.burakmetehan.com.tr/docs/sdk/node.md)). - Kotlin/JVM: `cd sdk/kotlin && ./gradlew publishToMavenLocal`, then `mavenLocal()` and `io.github.burakmetehan.authbase:authbase-ktor:0.1.0` or `…:authbase-spring-boot-starter:0.1.0` ([Kotlin SDK](https://authbase.burakmetehan.com.tr/docs/sdk/kotlin.md)). - Anything else: the API is plain JSON over HTTP and tokens are standard JWTs; the [integration guide](https://authbase.burakmetehan.com.tr/docs/integration.md) has Node, Go and Python code. 2. Sign-up, login, refresh and logout go from your backend to authbase with the API key. Pass the end-user's IP as `client_ip`: rate limiting and the audit log use it. 3. Give your clients the tokens. Keep the refresh token out of JavaScript (an `HttpOnly` cookie, or your server's session); the access token is short-lived (15 minutes by default). 4. Protect routes by verifying the access token locally (the SDK's `requireAuth` / `authenticate("authbase")` / Spring's resource server). 5. Before production, read [Self-hosting](https://authbase.burakmetehan.com.tr/docs/self-hosting.md): HTTPS, the reverse proxy, backups and the key runbooks. Stop everything with `docker compose -f deploy/compose.yaml down` (add `-v` to delete the database too). # Concepts > Accounts, apps, API keys, access and refresh tokens, signing keys and email, in two pages. authbase is an authentication service you run yourself. Your products keep their own databases and business logic; authbase owns their users' credentials and issues the tokens they use. ``` end-user ──▶ your backend ──(API key)──▶ authbase ──▶ PostgreSQL │ │ └── verifies tokens ◀── JWKS (public keys, cached) ``` ## Who is who | | What | Authenticates with | |---|---|---| | **Instance** | One authbase deployment: one binary, one PostgreSQL, one master key. Run by an **operator**. | — | | **Account** | A developer who uses the dashboard. Owns apps. | email + password → dashboard session cookie | | **App** | One of your products (e.g. *Cashmate*). Has its own users, keys and settings; nothing is shared between apps. | — | | **API key** | Your backend's credential for one app: `sk_live_…`, server-side only. | `Authorization: Bearer sk_live_…` | | **End-user** | A user of your product, stored in an app. | email or username + password, through your backend | An app is identified by an immutable **slug**; its **issuer** is `{PUBLIC_URL}/apps/{slug}`, and its **app id** (a UUID) is the audience of its tokens. Both are on the app's overview page in the dashboard. ## Tokens A successful login returns two tokens: | | Access token | Refresh token | |---|---|---| | Form | JWT (RFC 9068), signed with Ed25519 (`alg: EdDSA`, `typ: at+jwt`) | opaque `rt_…`, stored only as a hash | | Lifetime | 15 minutes by default (5–60, per app) | 30 days by default (1 hour–1 year, per app), renewed on every refresh | | Who checks it | **your backend**, locally, with the app's public keys | authbase, when you call `/v1/auth/refresh` | | Revocable | no: it expires. Keep it short. | yes: logout, "sign out everywhere", password change | The access token carries `sub` (the user id), `email`, `email_verified`, `username`, `roles`, `sid` (the login it came from) and `client_id`/`aud` (the app id). User `metadata` is not in it: fetch the user when you need it. Every refresh returns a **new** refresh token and makes the old one unusable. Presenting a used one again is treated as theft: the whole chain from that login (the **family**) is revoked and every holder must log in again (`refresh_token_reused`). Consequences worth knowing: - Disabling or deleting a user, or signing them out everywhere, stops new tokens at once; access tokens already issued stay valid until they expire (at most the access-token lifetime). - Roles changed in authbase appear in the next token (login or refresh). ## Signing keys and the JWKS Each app has one **active** Ed25519 signing key. Its public half is published, with any **retiring** key, at `{issuer}/.well-known/jwks.json` (cacheable for 5 minutes); the private half is stored encrypted under the instance's master key and never leaves authbase. **Rotating** a signing key (dashboard or API) makes a new active key; the old one keeps being published until every token it signed has expired, then disappears. Verifiers that refetch the JWKS on an unknown `kid` (the SDKs do, at most once a second) need no restart. ## API keys An app can have several API keys at once. **Rotate** creates a new key and gives every other key a grace period (24 hours by default) in which both work, so you can deploy the new one without downtime; **revoke** stops a key immediately. Keys are shown once and stored only as hashes. ## Email With SMTP configured, authbase sends password-reset and verification links (single-use, 30 minutes and 24 hours) and security notices when a password or email changes. Links go to authbase's **hosted pages**, which work without JavaScript, or to your own pages (`password_reset_url`, `email_verification_url` in the app's settings). Per app, `email_verification` is `off`, `optional` (sent, never blocks) or `required` (login waits for confirmation). ## Protection that is always on - **Rate limits** per client IP on login, sign-up and email requests, and per API key; **lockout** of an identifier after repeated failed logins (1 minute, doubling to 1 hour). Answers are `429` with `Retry-After`. - **Passwords**: 12–128 characters, checked against the 100,000 most common leaked passwords, hashed with Argon2id. - **Audit log** per app: logins, failures, token reuse, user and key changes, with actor, IP and time; filterable in the dashboard. ## Errors Every error is `application/problem+json` (RFC 9457) with a stable `code` to branch on (`invalid_credentials`, `account_locked`, `token_expired`, …), a `detail` for people and a `request_id` to quote when reporting a problem. The SDKs raise them as `AuthbaseError` / `AuthbaseException`. The full list is in the [API reference](https://authbase.burakmetehan.com.tr/api/). ## Where to go next - [Quick start](https://authbase.burakmetehan.com.tr/docs/quickstart.md): run it and integrate an example. - [Integration guide](https://authbase.burakmetehan.com.tr/docs/integration.md): the API calls and token checks in detail, with code for Node, Go and Python. - [Self-hosting](https://authbase.burakmetehan.com.tr/docs/self-hosting.md): production deployment and runbooks. - [Security model](https://authbase.burakmetehan.com.tr/docs/security-model.md): what is protected, how, and what is left to you. # Integrating authbase > The API calls and token checks for any language, with Node, Go and Python code and the error codes. Your backend talks to authbase for four things: registering users, logging them in, refreshing, and logging out. Everything else, above all verifying the access token on each request, happens **locally** against the app's JWKS. authbase is never on your hot path. For Node and the JVM, the SDKs do all of this for you: [`@authbase/node`](../sdk/typescript/README.md) (Express, Hono, Next.js) and [the Kotlin SDK](https://authbase.burakmetehan.com.tr/docs/sdk/kotlin.md) (Ktor, Spring Boot). Working backends for each, and for Go without an SDK, are in [the examples](https://authbase.burakmetehan.com.tr/docs/examples.md); the [quick start](https://authbase.burakmetehan.com.tr/docs/quickstart.md) runs one end to end. This page is the contract underneath, for any language. ## 1. Get an app and an API key In the dashboard (**New app**, then **API keys → Create API key**), or with the CLI on the server: ```sh authbase admin create-account --email you@example.com --password-stdin authbase admin create-app --owner you@example.com --slug cashmate authbase admin create-api-key --app cashmate --name production # prints sk_live_… ``` Note the app's issuer and JWKS URL, printed by `create-app`: ``` issuer: https://auth.example.com/apps/cashmate jwks_uri: https://auth.example.com/apps/cashmate/.well-known/jwks.json ``` ## 2. Register and log users in (server to server) Every call carries `Authorization: Bearer sk_live_…`. Requests and responses are JSON; errors are RFC 9457 problem documents with a stable `code` (see the [API reference](https://authbase.burakmetehan.com.tr/api/)). ```sh # register curl -s https://auth.example.com/v1/users \ -H 'Authorization: Bearer sk_live_…' -H 'Content-Type: application/json' \ -d '{"email":"ada@example.com","password":"correct horse battery","username":"ada"}' # log in; pass the end-user's IP so rate limiting and audit see it curl -s https://auth.example.com/v1/auth/login \ -H 'Authorization: Bearer sk_live_…' -H 'Content-Type: application/json' \ -d '{"identifier":"ada@example.com","password":"correct horse battery","client_ip":"203.0.113.9"}' ``` The login response: ```json { "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 900, "refresh_token": "rt_…", "refresh_expires_in": 2592000, "user": { "id": "…", "email": "ada@example.com", "roles": [], … } } ``` Hand both tokens to your client (cookie or body). When the access token expires, `POST /v1/auth/refresh` with the refresh token: you get a **new** pair and the old refresh token is dead. If a dead one is ever presented again, the whole family is revoked and the next refresh fails with `refresh_token_reused`. `POST /v1/auth/logout` revokes the family. ## 3. Verify access tokens locally Rules, in every language: 1. Fetch the JWKS from the app's `jwks_uri` and cache it (`Cache-Control: max-age=300`). Refetch when a `kid` is unknown (key rotation). 2. Pick the key by the token's `kid`. **Never** take keys from the token (`jku`, `x5u`, `jwk` headers) or from anywhere but your configured JWKS URL. 3. Accept only `alg: EdDSA`. Reject `none`, `HS*`, `RS*`. 4. Check `iss` equals the app's issuer, `aud` contains the app id, `exp` is in the future. 5. Check the header `typ` is `at+jwt` (RFC 9068). Other JWTs signed by the same key, such as ID tokens from the planned hosted login, are not access tokens. Claims: `sub` (user id), `email`, `email_verified`, `username` (absent when unset), `roles`, `sid` (login lineage), `amr`, `jti`, `client_id` (the app id, as `aud`). User `metadata` is **not** in the token; fetch the user when you need it. ### Node (jose, standard library only otherwise) ```js import { createRemoteJWKSet, jwtVerify } from "jose"; const ISSUER = "https://auth.example.com/apps/cashmate"; const APP_ID = "01a0…"; // the app's id (aud) const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`)); export async function verifyAccessToken(token) { const { payload } = await jwtVerify(token, JWKS, { issuer: ISSUER, audience: APP_ID, algorithms: ["EdDSA"], typ: "at+jwt", }); return payload; // payload.sub, payload.email, payload.roles, … } ``` ### Go (lestrrat-go/jwx) ```go import ( "context" "errors" "sync" "time" "github.com/lestrrat-go/httprc/v3" "github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jws" "github.com/lestrrat-go/jwx/v3/jwt" ) const issuer = "https://auth.example.com/apps/cashmate" const appID = "01a0…" var ( cache *jwk.Cache mu sync.Mutex lastRefresh time.Time ) const jwksURL = issuer + "/.well-known/jwks.json" // Fails fast at startup if authbase is unreachable or the JWKS is invalid. func init() { var err error // NewCache needs an unstarted httprc client; passing nil panics. if cache, err = jwk.NewCache(context.Background(), httprc.NewClient()); err != nil { panic(err) } if err = cache.Register(context.Background(), jwksURL, jwk.WithMinInterval(5*time.Minute)); err != nil { panic(err) } } // verifyAccessToken refetches the JWKS once (at most once a second) when a // token fails, so a signing-key rotation is picked up at once instead of // at the next scheduled refresh. func verifyAccessToken(ctx context.Context, token string) (jwt.Token, error) { tok, err := parse(ctx, []byte(token)) if err == nil || !mayRefresh() { return tok, err } if _, rerr := cache.Refresh(ctx, jwksURL); rerr != nil { return nil, errors.Join(err, rerr) } return parse(ctx, []byte(token)) } func mayRefresh() bool { mu.Lock() defer mu.Unlock() if time.Since(lastRefresh) < time.Second { return false } lastRefresh = time.Now() return true } func parse(ctx context.Context, token []byte) (jwt.Token, error) { set, err := cache.Lookup(ctx, jwksURL) if err != nil { return nil, err } tok, err := jwt.Parse(token, jwt.WithKeySet(set, jws.WithInferAlgorithmFromKey(false)), // kid + alg from the JWKS only jwt.WithIssuer(issuer), jwt.WithAudience(appID), ) if err != nil { return nil, err } // typ is covered by the signature Parse just verified. msg, err := jws.Parse(token) if err != nil { return nil, err } if typ, _ := msg.Signatures()[0].ProtectedHeaders().Type(); typ != "at+jwt" { return nil, errors.New("not an access token") } return tok, nil } ``` Private array claims such as `roles` come back from jwx as `[]any`: read them with `var roles []any; tok.Get("roles", &roles)` and convert each element (examples/go has a helper). ### Python (PyJWT) ```python import jwt from jwt import PyJWKClient ISSUER = "https://auth.example.com/apps/cashmate" APP_ID = "01a0…" jwks = PyJWKClient(f"{ISSUER}/.well-known/jwks.json", cache_keys=True) def verify_access_token(token: str) -> dict: key = jwks.get_signing_key_from_jwt(token) # picks by kid verified = jwt.decode_complete( token, key.key, algorithms=["EdDSA"], issuer=ISSUER, audience=APP_ID, ) if verified["header"].get("typ") != "at+jwt": # RFC 9068 raise jwt.InvalidTokenError("not an access token") return verified["payload"] ``` ## 4. Errors you will see | Situation | Status | `code` | |---|---|---| | Wrong password or unknown identifier | 401 | `invalid_credentials` | | Too many failures for an identifier | 429 | `account_locked` (+ `Retry-After`) | | Too many requests from one IP / key | 429 | `rate_limited` | | Disabled user | 403 | `user_disabled` | | Email verification required (app setting) | 403 | `email_not_verified` | | Refresh token unknown / expired / replayed | 401 | `refresh_token_invalid` / `refresh_token_expired` / `refresh_token_reused` | | Invalid request body | 422 | `validation_failed` with `errors[]` | | New password too common | 422 | `password_breached` (field named in `errors[]`) | | Reset or verification link unknown / expired / used | 401 / 401 / 409 | `token_invalid` / `token_expired` / `token_already_used` | | Email needed but the instance has no SMTP | 503 | `email_not_configured` | | Bad or revoked API key | 401 | `invalid_api_key` | Every problem document carries a `request_id`; quote it when reporting an issue, it matches the server's log line. ## 5. Password reset and email verification authbase sends these emails itself (the operator configures SMTP), so your backend makes one call each and never handles the mail. **Password reset.** When a user clicks "forgot password": ```sh curl -s -X POST $AUTHBASE/v1/auth/password-reset/request \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"email":"ada@example.com","client_ip":"203.0.113.9"}' # 202 always, registered or not: the response reveals nothing ``` The email links to authbase's hosted page (`{PUBLIC_URL}/apps/{slug}/reset-password`), which works without JavaScript and finishes the job: the user chooses a password, every refresh token of the user stops working, and the address counts as verified. Links are single use and expire after 30 minutes; a newer link replaces older ones. To keep users on your own site, set the app's `password_reset_url` (for example `https://app.example.com/reset`). The email then links to `https://app.example.com/reset?token=…`, and your page posts the token and the new password: ```sh curl -s -X POST $AUTHBASE/v1/auth/password-reset/confirm \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"token":"","new_password":""}' # 204; 422 password_breached leaves the link usable ``` **Email verification.** Set the app's `email_verification`: | Mode | On sign-up (`POST /v1/users`) | At login | |---|---|---| | `off` (default) | nothing is sent | never checked | | `optional` | verification email sent | never blocks; `email_verified` tells you | | `required` | verification email sent | `403 email_not_verified` until confirmed | Send another link with `POST /v1/auth/email-verification/request` (`{"user_id": …}`); it works in every mode. The hosted page (`/apps/{slug}/verify-email`) shows a "Confirm" button rather than confirming on open, so mail filters that follow links cannot verify an address. With your own `email_verification_url`, post the token to `POST /v1/auth/email-verification/confirm`. Links expire after 24 hours. **Security notices.** authbase tells users when their password changes and tells the old address when their email changes. If you send your own, set the app's `security_notifications` to `false`. ## 6. Key rotation Rotating the signing key (dashboard or `POST /v1/apps/{id}/signing-keys/rotate`) adds a new key to the JWKS while the old one stays published until every token it signed has expired. A verifier that refetches the JWKS on an unknown `kid` needs no restart. Rotating API keys gives the old key a grace period (default 24 h) so you can roll deployments. # @authbase/node > @authbase/node: a typed client, offline token verification and Express, Hono and Next.js helpers. authbase for Node backends (Node 20.19+, ESM): a typed client for the end-user API and offline access-token verification, with helpers for Express, Hono and Next.js. Not on npm yet (it will be at the public release): install it from a packed tarball. ```sh cd sdk/typescript && npm ci && npm run build && npm pack # → authbase-node-0.1.0.tgz cd your-app && npm install ../authbase/sdk/typescript/authbase-node-0.1.0.tgz ``` ## The client One per app, with the app's API key from the environment (never in code): ```ts import { Authbase, AuthbaseError } from "@authbase/node"; const authbase = new Authbase({ url: process.env.AUTHBASE_URL!, apiKey: process.env.AUTHBASE_API_KEY! }); await authbase.users.create({ email, password }); const tokens = await authbase.auth.login({ identifier: email, password, client_ip: req.ip }); const next = await authbase.auth.refresh({ refresh_token: tokens.refresh_token }); await authbase.auth.logout({ refresh_token: next.refresh_token }); ``` Every method throws `AuthbaseError` on a non-2xx answer: `status`, `code` (branch on this), `detail`, `requestId`, `errors` (per field, for 422) and `retryAfter` (429/503). An unreachable server is `code: "network_error"`. ## Verifying access tokens Tokens are verified locally against the app's JWKS: fetched once, cached for 5 minutes, refetched (at most once a second) when a token carries an unknown `kid`, so a signing-key rotation needs no restart. Only `EdDSA`, `typ: at+jwt`, the app's issuer and the app id as `aud` are accepted. ```ts const claims = await authbase.verifyAccessToken(token); // learns issuer and app id once from GET /v1/app ``` A service that only checks tokens needs no API key: ```ts import { createVerifier } from "@authbase/node"; const verify = createVerifier({ issuer: "https://auth.example.com/apps/cashmate", appId: "01a0…" }); ``` A bad token throws `AuthbaseTokenError` (`token_expired`, worth a refresh, or `token_invalid`). A JWKS that cannot be fetched throws something else: that is an outage, not the caller's fault. ## Framework helpers Each takes the client or `{ issuer, appId }`, and optionally `{ roles }` (all required). Missing or bad tokens get 401 with `WWW-Authenticate: Bearer` (RFC 6750), missing roles 403, an unreachable JWKS 503; bodies are problem+json. ```ts // Express import { requireAuth } from "@authbase/node/express"; app.get("/me", requireAuth(authbase), (req, res) => res.json({ id: req.auth!.sub })); // Hono import { requireAuth, type AuthVariables } from "@authbase/node/hono"; const app = new Hono<{ Variables: AuthVariables }>(); app.get("/me", requireAuth(authbase, { roles: ["admin"] }), (c) => c.json({ id: c.get("auth").sub })); // Next.js route handler (app/api/me/route.ts) import { withAuth } from "@authbase/node/next"; export const GET = withAuth(authbase, async (req, { auth }) => Response.json({ id: auth.sub })); ``` ## Development `npm run check` regenerates the client from `api/openapi/openapi.yaml` (and fails if the committed copy is stale), typechecks, tests and builds. `AUTHBASE_TEST_URL=http://localhost:8080 npm test` also runs the integration test against a real instance. # Kotlin and JVM SDK > The Kotlin/JVM client and verifier, a Ktor authentication provider and a Spring Boot starter. Three modules (JVM 17+). Not on Maven Central yet (they will be at the public release): install them into your local Maven repository and depend on them from your services. ```sh cd sdk/kotlin && ./gradlew publishToMavenLocal ``` ```kotlin // your service's build.gradle.kts repositories { mavenLocal(); mavenCentral() } dependencies { implementation("io.github.burakmetehan.authbase:authbase-ktor:0.1.0") // Ktor implementation("io.github.burakmetehan.authbase:authbase-spring-boot-starter:0.1.0") // or Spring Boot } ``` | Module | What | |---|---| | `authbase-core` | `Authbase` (suspend client for the end-user API) and `AccessTokenVerifier` | | `authbase-ktor` | a Ktor `Authentication` provider and `requireRoles` | | `authbase-spring-boot-starter` | auto-configured client, Spring Security `JwtDecoder`, `roles` → `ROLE_…` | ## The client One per app, with its API key from the environment (never in code): ```kotlin val authbase = Authbase(url = System.getenv("AUTHBASE_URL"), apiKey = System.getenv("AUTHBASE_API_KEY")) authbase.users.create(CreateUserRequest(email = email, password = password)) val tokens = authbase.auth.login(LoginRequest(identifier = email, password = password, clientIp = ip)) val next = authbase.auth.refresh(RefreshRequest(refreshToken = tokens.refreshToken)) authbase.users.update(userId) { roles = listOf("admin"); username = null } // sends only these; null clears ``` Calls are `suspend` and throw `AuthbaseException` on a non-2xx answer: `status`, `code` (branch on this), `detail`, `requestId`, `errors` (per field, for 422), `retryAfter` (429/503). An unreachable server is `code = "network_error"`. ## Verifying access tokens Offline, against the app's JWKS: fetched once, cached for 5 minutes, refetched (at most once a second) for an unknown `kid`, so a signing-key rotation needs no restart. Only `EdDSA`, `typ: at+jwt`, the app's issuer and the app id as `aud` are accepted. Ed25519 is verified with the JDK's own implementation (Java 15+), so no Tink or BouncyCastle is pulled in. ```kotlin val claims = authbase.verifyAccessToken(token) // learns issuer and app id from the API key, once val verifier = AccessTokenVerifier(VerifierOptions(issuer = "https://auth.example.com/apps/cashmate", appId = "01a0…")) ``` A bad token throws `AuthbaseTokenException` (`reason`: `MISSING`, `EXPIRED` — worth a refresh — or `INVALID`); an unreachable JWKS throws `AuthbaseUnavailableException`, an outage rather than the caller's fault. ## Ktor ```kotlin install(Authentication) { authbase { client(authbase) } } // or verifier(VerifierOptions(issuer, appId)) routing { authenticate("authbase") { get("/me") { call.respond(call.authbase!!.subject) } requireRoles("admin") { delete("/posts/{id}") { /* … */ } } } } ``` 401 (`WWW-Authenticate: Bearer`, RFC 6750) for a missing or bad token, 403 for missing roles, 503 when the JWKS cannot be fetched; bodies are problem+json. ## Spring Boot Add `spring-boot-starter-security-oauth2-resource-server` and set either the client's properties or the verifier's: ```properties authbase.url=https://auth.example.com authbase.api-key=${AUTHBASE_API_KEY} # or, to verify without an API key: # authbase.issuer=https://auth.example.com/apps/cashmate # authbase.app-id=01a0… ``` ```kotlin @Bean fun security(http: HttpSecurity): SecurityFilterChain = http .authorizeHttpRequests { it.requestMatchers("/admin/**").hasRole("admin"); it.anyRequest().authenticated() } .oauth2ResourceServer { it.jwt { } } .build() ``` The starter supplies the `JwtDecoder` (authbase's verifier) and maps the `roles` claim to `ROLE_…` authorities; `@AuthenticationPrincipal jwt: Jwt` gives the claims (`jwt.subject` is the user id). An `Authbase` bean is there for calls; its methods are `suspend`, so Spring MVC controllers that call them need `org.jetbrains.kotlinx:kotlinx-coroutines-reactor` (Spring runs suspend handlers through Reactor). Define your own bean of any of these types to replace it. ## Development `./gradlew build` compiles (warnings are errors), tests and packages every module. `ModelsMatchSpecTest` fails when a model's fields drift from `api/openapi/openapi.yaml`. `AUTHBASE_TEST_URL=http://localhost:8080 ./gradlew test` also runs the integration test against a real instance. # Example backends > Working Express, Ktor, Spring Boot and Go backends with the same routes, and the smoke test CI runs. Four small backends on authbase, with the same routes so one script (`smoke.sh`) tests them all; CI runs every one against the compose stack. | | Stack | Port | Uses | |---|---|---|---| | [`express`](express) | Node, Express 5 | 3001 | `@authbase/node` | | [`ktor`](ktor) | Kotlin, Ktor 3 | 3002 | `authbase-ktor` | | [`spring`](spring) | Kotlin, Spring Boot 4 | 3003 | `authbase-spring-boot-starter` | | [`go`](go) | Go, net/http | 3004 | no SDK: `lestrrat-go/jwx`, as in the [integration guide](https://authbase.burakmetehan.com.tr/docs/integration.md) | | Route | | |---|---| | `POST /signup` `{email, password}` | creates the user in authbase and logs in → 201 `{user_id, access_token, refresh_token}` | | `POST /login` `{email, password}` | → `{access_token, refresh_token}` | | `POST /refresh` `{refresh_token}` | → a new pair; the old refresh token is dead | | `POST /logout` `{refresh_token}` | → 204 | | `GET /me` | Bearer token → `{id, email, roles}`; verified locally | | `GET /admin` | needs the `admin` role → 403 without it | ## Run one Start authbase and get an API key ([quick start](https://authbase.burakmetehan.com.tr/docs/quickstart.md)), then, with the key only in your environment: ```sh export AUTHBASE_URL=http://localhost:8080 AUTHBASE_API_KEY=sk_live_… # Express: the SDK is used from source, so build it first (cd sdk/typescript && npm ci && npm run build) && cd examples/express && npm install && npm start # Go cd examples/go && go run . # Ktor / Spring Boot: Gradle builds the Kotlin SDK from source (includeBuild) cd examples/ktor && ./gradlew run cd examples/spring && ./gradlew bootRun # in another shell examples/smoke.sh http://localhost:3001 ``` # Self-hosting > Production: HTTPS and the reverse proxy, email, health and metrics, backups, upgrades and key runbooks. Running authbase for real: what it needs, how to put it behind HTTPS, what to back up, how to upgrade, and the runbooks for rotating keys. Start with the [quick start](https://authbase.burakmetehan.com.tr/docs/quickstart.md) if you have not run it yet. ## What it needs - **authbase**: one stateless binary, from a release archive (Linux or macOS, amd64 or arm64, with checksums and an SBOM) or the container image built from `deploy/Dockerfile`; `authbase version` says which build it is. Memory follows CPUs: a password check holds 19 MiB for a moment and at most max(2, CPUs) run at once. Measured: 30 MiB idle, about 410 MiB on 12 CPUs during a burst of 60 simultaneous logins. 1–2 vCPUs and 512 MB serve a small product comfortably. - **PostgreSQL 16 or newer** (CI tests 16 and 18). All state lives here. - **One secret, the master key** (`AUTHBASE_MASTER_KEY`, 32 random bytes, base64): it encrypts the apps' signing keys and queued email. Generate it with `openssl rand -base64 32` or `authbase keygen`. - **An SMTP relay**, if you want password reset, verification and security emails. Every setting is an environment variable; `deploy/.env.example` lists them with comments. Secrets never go in the image, the compose file or the logs. `authbase serve --check-config` validates the configuration and exits. ## Deploy with Docker Compose `deploy/compose.yaml` is the reference deployment: authbase and PostgreSQL, configured from `deploy/.env`. The image is built from source on the server. The `authbase-self-host` skill ([Claude skills](https://authbase.burakmetehan.com.tr/docs/skills.md)) walks through a fresh server step by step. ```sh cp deploy/.env.example deploy/.env # set AUTHBASE_MASTER_KEY, POSTGRES_PASSWORD, AUTHBASE_PUBLIC_URL docker compose -f deploy/compose.yaml up -d --build ``` For production, in `deploy/.env`: ```sh AUTHBASE_PUBLIC_URL=https://auth.example.com # https: Secure, __Host- cookies and HSTS follow from it AUTHBASE_PORT=127.0.0.1:8080 # publish on loopback only; the reverse proxy is the way in AUTHBASE_TRUSTED_PROXIES=172.16.0.0/12 # where the proxy's requests come from, see below AUTHBASE_ALLOW_SIGNUP=false # once every developer who needs an account has one ``` Anything else that runs a container and a PostgreSQL works the same way: the container needs the variables above plus `AUTHBASE_DATABASE_URL`. ## HTTPS and the reverse proxy authbase speaks plain HTTP and expects a reverse proxy to terminate TLS. Two things matter: 1. `AUTHBASE_PUBLIC_URL` is the https origin users see. It is the token issuer (changing it later invalidates every issued token and every integration's configuration), the base of email links, and it turns on `Secure` + `__Host-` session cookies and HSTS. 2. `AUTHBASE_TRUSTED_PROXIES` lists the addresses the proxy's requests arrive from (CIDRs). Only then is `X-Forwarded-For` believed, so rate limits, lockout and the audit log see the real client IP. A proxy on the Docker host reaches the container from the Docker network's gateway (within `172.16.0.0/12` by default); check the `ip` of a request in the audit log if unsure. Leave it empty when nothing sits in front. Caddy (certificates included): ``` auth.example.com { reverse_proxy 127.0.0.1:8080 } ``` nginx: ```nginx server { listen 443 ssl; server_name auth.example.com; # ssl_certificate …; ssl_certificate_key …; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; } } ``` Serve authbase on its own host name; the dashboard, the API and the hosted pages share it, and the dashboard needs no CORS. ## Email Set `AUTHBASE_SMTP_HOST`, `_PORT`, `_USER`, `_PASSWORD` and `_FROM`. `AUTHBASE_SMTP_TLS` is `starttls` by default and then required (a relay without it is refused), `tls` for implicit TLS (port 465), or `none` for a local relay only. Mail is queued in the database, encrypted, and sent in the background with retries, so requests never wait for SMTP. Without SMTP, email endpoints answer `503 email_not_configured` and apps cannot turn email verification on. Templates and hosted pages can be overridden with files under `AUTHBASE_TEMPLATES_DIR`. ## Health and metrics - `GET /healthz`: 200 while the process runs (liveness). - `GET /readyz`: 200 when the database answers and migrations are current, 503 otherwise (readiness). - `/metrics` (Prometheus) is served only on its own listener, off unless `AUTHBASE_METRICS_ADDR` is set, and never on the public port. With compose, set `AUTHBASE_METRICS_ADDR=:9090` and publish it to loopback (`127.0.0.1:9090:9090`) or your monitoring network only. Worth alerting on: `authbase_http_requests_total{code=~"5.."}` rising, `authbase_rejections_total` (429s: an attack or a misconfigured client), `authbase_audit_events_total{action="token.reuse_detected"}` (possible token theft), `authbase_email_outbox_queued` growing (SMTP down), and the database pool (`authbase_db_connections_*`). ## Backups A backup is **the database and the master key**, and they must be kept apart: the database alone cannot sign tokens (its signing keys are encrypted), and the key alone is useless. ```sh # database, compressed custom format docker compose -f deploy/compose.yaml exec -T postgres pg_dump -U authbase -Fc authbase > authbase-$(date +%F).dump # restore, with authbase stopped docker compose -f deploy/compose.yaml exec -T postgres pg_restore -U authbase -d authbase --clean --if-exists < authbase-2026-09-28.dump ``` Keep the master key in a password manager or secret store, never next to the dumps. A dump is readable with the master key that was current when it was taken: after a master key rotation, keep the old key as long as you keep older dumps. Practise a restore now and then; a restored instance started with the right key answers `/readyz` and signs tokens. ## Upgrades 1. Back up the database. 2. Update the checkout (or image) and restart: `git pull && docker compose -f deploy/compose.yaml up -d --build`. 3. Migrations run at start-up (`AUTHBASE_AUTO_MIGRATE=true`, the default). With `false`, run `authbase migrate up` yourself; until then `/readyz` answers 503. Going back to an older version means restoring the backup taken before the upgrade. Several instances can run side by side (lockout, email sending and the background sweeper coordinate through the database); the per-IP rate limits are counted per instance. ## Runbooks ### Rotate an app's signing key When: on a schedule (yearly, say) or if you suspect the key leaked. Dashboard: **App → API keys → Signing keys → Rotate signing key**, or `POST /v1/apps/{appId}/signing-keys/rotate`. New tokens are signed with the new key immediately; the old key stays in the JWKS until the tokens it signed have expired (access-token lifetime + 5 minutes), then is retired. Verifiers that refetch the JWKS on an unknown `kid` (both SDKs, the examples) pick it up by themselves; ones that only refresh on a timer reject new tokens until their next refresh. ### Rotate or revoke an API key Routine: **Rotate**, which creates a new key and gives the old ones a grace period (the app's setting, 24 hours by default). Put the new key in your backend's environment and deploy; the old key stops working when the grace period ends. Leaked: **Revoke** it (it stops at once), create a new one, deploy. Then check the audit log, filtered by the key's id as actor, for what it was used for. ### Rotate the master key When: on a schedule, when someone who knew it leaves, or if it may have leaked. Takes a few seconds of downtime. 1. **Back up the database** (above). 2. **Generate the new key** and keep it in your secret store: `openssl rand -base64 32`. 3. **Stop authbase** (every instance): `docker compose -f deploy/compose.yaml stop authbase`. 4. **Re-seal everything under the new key.** The new key is read from the environment, never a flag; `read -s` keeps it out of the shell history: ```sh read -rs AUTHBASE_NEW_MASTER_KEY && export AUTHBASE_NEW_MASTER_KEY # paste the new key docker compose -f deploy/compose.yaml run --rm -e AUTHBASE_NEW_MASTER_KEY authbase admin rotate-master-key unset AUTHBASE_NEW_MASTER_KEY ``` It re-encrypts every signing key and queued email in one transaction and records `instance.master_key_rotated` in the audit log. If it fails, nothing changed. Running it again is harmless. 5. **Switch the key**: set `AUTHBASE_MASTER_KEY` in `deploy/.env` (or your secret store) to the new key. 6. **Start authbase**: `docker compose -f deploy/compose.yaml up -d authbase`. authbase checks at start-up that its key opens the database's signing keys; an instance still on the old key refuses to start with `AUTHBASE_MASTER_KEY does not open this database's signing keys`. Keep the old key as long as you keep backups taken before step 4. Tokens, API keys and passwords are unaffected: users notice nothing. ### After a leak | What leaked | Do | |---|---| | An API key | Revoke it; issue a new one (above). | | A database dump | Nothing is directly usable: passwords are Argon2id hashes, API keys and refresh tokens SHA-256 hashes, signing keys and queued email encrypted. Emails, usernames, roles and metadata are exposed; judge whom to inform. Rotate the master key if it could have leaked as well. | | The master key alone | Rotate the master key. | | The master key **and** the database | Rotate the master key, then rotate **every app's signing key** (an attacker could have minted tokens), and tell integrators. Consider a password reset for all users if hashes should be treated as exposed. | | A dashboard account | Reset its password; its sessions end. Review the apps' audit logs. | # Security model > Trust boundaries, how each secret is stored, token and abuse controls, and what is left to you. What authbase protects, how, and what stays your responsibility. Each release is reviewed requirement by requirement against OWASP ASVS 4.0.3, level 2. ## Trust boundaries | Party | Trusted with | Never gets | |---|---|---| | **Operator** | the host, the database, the master key | end-users' passwords (only hashes exist) | | **Account** (developer) | its apps: users, keys, settings, audit log | other accounts' apps (every app route checks ownership; another account's app is a 404) | | **API key** | one app's end-user API | the management API, other apps | | **End-user** | their own tokens | anything but what your backend exposes | | **Your backend** | an API key, end-users' tokens in transit | signing keys (tokens are verified with public keys) | ## Secrets and how they are stored | Secret | At rest | |---|---| | End-user and account passwords | Argon2id (19 MiB, t=2), NFKC-normalized first; never logged or emailed | | API keys (`sk_live_`, 256 bits) | SHA-256; shown once | | Refresh tokens (`rt_`, 256 bits) | SHA-256 | | Dashboard sessions (256 bits) | SHA-256; the cookie is `HttpOnly`, `Secure`, `SameSite=Lax`, `__Host-` on https | | Reset and verification links | SHA-256; single use, 30 minutes / 24 hours, voided by a newer link or a password or email change | | Apps' Ed25519 signing keys | AES-256-GCM under the master key, bound to their app and key id | | Queued email (it carries live links) | AES-256-GCM under the master key; deleted once sent | | The master key | only in the environment; never in the image, the database or logs | A stolen database therefore yields no usable credential; a stolen master key alone yields nothing. Both together allow forging tokens, which is why the runbook for that case rotates every signing key ([after a leak](https://authbase.burakmetehan.com.tr/docs/self-hosting.md#after-a-leak)). ## Tokens - Access tokens are EdDSA-signed JWTs with `typ: at+jwt` (RFC 9068), an issuer per app and the app id as audience, so a token for one app is worthless to another, and other JWTs signed by the same key are not access tokens. Verifiers must accept only `EdDSA` and take keys only from the configured JWKS URL, never from the token (the SDKs do). - They cannot be revoked; their lifetime (15 minutes by default, at most 60) bounds how long a disabled or signed-out user keeps access. - Refresh tokens rotate on every use. Re-use of a spent one revokes the whole family, which ends a stolen token's life at the next legitimate refresh (`token.reuse_detected` in the audit log). - A password change or reset revokes every refresh token of the user. ## Abuse controls - Per-IP rate limits on login, sign-up, reset and verification requests and dashboard sign-in; per-API-key limits. Behind a proxy they depend on `AUTHBASE_TRUSTED_PROXIES` being right. - Per-identifier lockout after 5 failures in 15 minutes (1 minute, doubling to 1 hour), shared by every instance through the database. - At most max(2, CPUs) password hashes run at once; beyond that requests wait up to 2 seconds, then get 429, so a login flood cannot exhaust memory. - Login answers the same for an unknown identifier and a wrong password; reset and verification requests answer 202 for any address. ## Web surfaces - The dashboard is served by authbase itself: one origin, no CORS, a Content-Security-Policy of `'self'` with nothing inline, and CSRF protection (`Sec-Fetch-Site` / `Origin`) on every state-changing request. - The hosted reset and verification pages run no code beyond one same-origin script (a "Show passwords" checkbox), are never cached, and act only on a POST, so link-scanning mail filters cannot use them. - Every response carries `X-Content-Type-Options`, `Referrer-Policy` and, on https, HSTS. ## Accountability Every change to apps, keys and users, and every login, failure, lockout and token reuse, is written to the app's audit log in the same transaction as the change, with the actor (account, API key, user or the operator's CLI), the client IP and the time. Master key rotations are recorded as instance events. ## What is yours to do As an **integrator**: - Keep the API key server-side, in the environment. Never ship it to a browser or a mobile app. - Verify every access token (the SDK middleware, or the rules in [integration guide](https://authbase.burakmetehan.com.tr/docs/integration.md#3-verify-access-tokens-locally)). - Keep refresh tokens out of reach of scripts: an `HttpOnly` cookie or your server's session, not `localStorage`. - Pass the end-user's IP (`client_ip`) on login and refresh, so rate limits and the audit log mean something. - Keep access tokens short-lived if you rely on disabling users. As an **operator**: - Serve it over HTTPS only, behind a proxy you list in `AUTHBASE_TRUSTED_PROXIES`. - Keep the master key in a secret store, apart from database backups. - Close sign-up (`AUTHBASE_ALLOW_SIGNUP=false`) once your developers have accounts. - Keep `/metrics` off the internet (it is on its own listener for that). ## Known limits - No multi-factor authentication yet, for end-users or the dashboard (planned). - No social login or hosted login page yet (planned). - Per-IP rate limits are counted per instance; lockout is shared. - Access tokens cannot be revoked before they expire (by design, above). # Docs for agents > llms.txt, markdown copies of every page, the OpenAPI spec and a prompt for coding agents. Coding agents read these docs as well as people do, and often more of them. Everything on this site is available in a form an agent can fetch and read without rendering a page. ## What to point an agent at | URL | What | |---|---| | [`/llms.txt`](https://authbase.burakmetehan.com.tr/llms.txt) | An index of every page with a one-line summary ([llmstxt.org](https://llmstxt.org)). Start here. | | [`/llms-full.txt`](https://authbase.burakmetehan.com.tr/llms-full.txt) | Every page in one file, for agents that prefer one fetch. | | `/docs/.md` | Each page as plain markdown: add `.md` to a page's path, e.g. [`/docs/integration.md`](https://authbase.burakmetehan.com.tr/docs/integration.md). | | [`/openapi.yaml`](https://authbase.burakmetehan.com.tr/openapi.yaml) | The OpenAPI 3.0 spec: every endpoint, request and response body, and error code. | The markdown copies are generated from the same files as the pages you are reading, so they never disagree. ## With Claude The [Claude skills](https://authbase.burakmetehan.com.tr/docs/skills.md) come with the first public release. Once installed, Claude follows authbase's rules on its own (API key only in the environment, tokens verified locally, refresh tokens out of JavaScript, the error codes to branch on) and checks its work with the skill's `verify-flow.sh` against your instance. ## With any other agent Give it the docs and the rules in the prompt. A starting point: ```text Add authentication to this backend with authbase, a self-hosted auth service. Read https://authbase.burakmetehan.com.tr/llms.txt and the pages it links to before writing code: at least the integration guide and the SDK page for this stack. The instance is in AUTHBASE_URL and the app's API key in AUTHBASE_API_KEY. Both are already in the environment; never write the key into code, a committed file, a log line or your reply. Rules: - Sign-up, login, refresh and logout go from this backend to authbase. Pass the end-user's IP as client_ip. - Verify access tokens locally against the app's JWKS (EdDSA only, typ at+jwt, issuer, audience = app id, expiry). Never call authbase per request, and never decode a token without verifying it. - Browser clients get the refresh token in an HttpOnly, Secure, SameSite cookie, never in localStorage. - Branch on the error `code` in authbase's problem+json responses. ``` ## Keeping agents honest Two things catch an integration that only looks right: - **The error codes are the contract.** The [integration guide's table](https://authbase.burakmetehan.com.tr/docs/integration.md#4-errors-you-will-see) and the [API reference](https://authbase.burakmetehan.com.tr/api/) list them; an agent should branch on `code`, not on message text. - **Run the flow end to end.** The integrate skill's `verify-flow.sh` signs a user up through your backend, checks a protected route and a role-only route, tries a wrong password, refreshes, replays a spent refresh token and logs out, checking every answer. # Claude skills > Two Agent Skills that teach Claude to integrate authbase into an app and to run it on a server. Two [Agent Skills](https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview) that teach Claude to work with authbase. Each folder is self-contained: take one, both, or neither. | Skill | Use it when you want Claude to… | |---|---| | [`authbase-integrate`](authbase-integrate/SKILL.md) | add authbase to an app's backend: sign-up, login, refresh, logout, protected routes, roles, password reset (Node, Kotlin/JVM, Go, any language) | | [`authbase-self-host`](authbase-self-host/SKILL.md) | put authbase on a Linux server with HTTPS, back it up, upgrade it, rotate keys, handle a leak | Both keep secrets out of code, files and chat, and ask before anything destructive. They carry their own references and scripts, so they work in any project, without this repository next to them. ## Install The skills are published with the first public release, as zips attached to each release. Once they are out, install them like this. **Claude Code**, for all your projects: ```sh mkdir -p ~/.claude/skills cp -R skills/authbase-integrate ~/.claude/skills/ # and/or authbase-self-host ``` or for one project only: copy the folder into that project's `.claude/skills/`. Claude picks a skill up by itself when a task matches its description; you can also ask for it by name. **Claude apps (claude.ai, desktop)**: zip a skill (`skills/package.sh` writes `build/skills/.zip`, and every release carries the zips), then upload it under Settings → Capabilities → Skills. **Without this repository**: download `authbase-integrate.zip` or `authbase-self-host.zip` from a release, or just the folder from GitHub, and install it as above. ## Keeping them true The skills describe this version of authbase. `api/tests/skills_test.go` fails when a skill names an endpoint the API does not have, when its error code table differs from the API's, or when a script stops being runnable; update the skill in the same change as the API. The integrate skill was tested the way it will be used: a fresh Claude, given only the skill, added authbase to a small Express app (sign-up, login, cookie refresh, per-user data, an admin route), and `scripts/verify-flow.sh` then passed all eleven checks against a real instance. Its feedback shaped the Express recipe and the error table.