Authentication

Accounts for your app's users — e-mail and password, e-mail code, magic link, social sign-in, and guest sessions. Tokens are JWTs that flow straight into your Row-Level Security policies.

POST  /auth/v1/signup · /token · /otp · /verify
GET   /auth/v1/verify  (magic link, browser)
GET   /auth/v1/authorize · /callback  (OAuth, browser)
POST  /auth/v1/factors  (MFA)
GET · PUT  /auth/v1/user
GET   /auth/v1/user/identities/authorize · DELETE /…/{id}
POST  /auth/v1/logout
GET   /auth/v1/.well-known/jwks.json

Sign up

curl -X POST "https://<ref>.kethosbase.com/auth/v1/signup" \
  -H "Authorization: Bearer kbp_<publishable-key>" \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@example.com", "password": "at-least-10-chars"}'

Response (201):

{
  "access_token": "eyJ…",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "kbr_…",
  "user": { "id": "…", "email": "alice@example.com", "created_at": "…" }
}

Passwords must be 10–128 characters. A taken e-mail answers 409 email_taken.

Leaked-password protection. A password known to have appeared in a public breach is refused with 400 leaked_password at sign-up, password reset, and self-service password change. The check runs entirely in-house — no candidate password is ever sent to a third party. Existing accounts are never re-validated on sign-in.

Sign in and refresh

/auth/v1/token handles both grants:

{ "grant_type": "password", "email": "…", "password": "…" }

{ "grant_type": "refresh_token", "refresh_token": "kbr_…" }

Access tokens live for 1 hour; refresh tokens for 30 days and are rotated on every use — using one twice answers 401 invalid_grant.

grant_type may be sent in the query string (POST /auth/v1/token?grant_type=password) instead of the body, so standard client libraries work unchanged.

Sign in with an e-mail code

Passwordless sign-in: request a one-time code, then exchange it for a session. The same flow signs a new user up on first use.

POST /auth/v1/otp
{ "email": "alice@example.com" }          → 200 {}  (code sent by e-mail)

POST /auth/v1/verify
{ "type": "email", "email": "alice@example.com", "token": "123456" }

/verify returns the same session payload as /token. Codes are 6 digits, expire after 10 minutes, are single-use, and lock after 5 wrong attempts. /otp always answers 200 — it never reveals whether the address exists; pass "create_user": false to skip sending to unknown addresses.

Sign in with a magic link

The clickable counterpart of the e-mail code. The same /otp call can mint a link alongside the code; the link points at GET /auth/v1/verify, which the user's browser opens directly. The endpoint verifies the token server-side and redirects back to your app with the session in the URL fragment:

GET /auth/v1/verify?token=kbl_<link-token>&type=magiclink&redirect_to=https://app.example.com/welcome

→ 302 Location: https://app.example.com/welcome#access_token=eyJ…&refresh_token=kbr_…&expires_in=3600&token_type=bearer&type=magiclink

On a bad or expired link it redirects to the same allowlisted target with #error=access_denied&error_code=otp_expired&… instead. The link token is high-entropy and single-use, so unlike the 6-digit code it needs no attempt cap. Client libraries that read the session from the URL pick it up automatically.

Redirect allowlist

Because the verify endpoint hands the session to whatever redirect_to it is given, each project keeps an allowlist so a crafted link can't divert a user's tokens to another site. It has two parts, set from the project's dashboard settings:

The allowlist is empty by default, so magic links and social sign-in stay inert until it is configured. A disallowed target is refused with 400 bad_redirect, checked both when the link is issued and again when it is redeemed.

PKCE or implicit. By default the session comes back in the URL fragment (implicit flow). To use PKCE, begin the flow with a code_challenge (method S256) — the redirect then carries a single-use ?code= instead of the session, which you exchange for the session:
POST /auth/v1/token?grant_type=pkce
{ "auth_code": "<code from ?code=>", "code_verifier": "<the verifier>" }
→ { "access_token": "…", "refresh_token": "…", "user": { … } }

The authorization code is single-use and expires in five minutes; the verifier is checked as SHA-256 against the challenge you sent, so an intercepted ?code= is useless without the verifier.

Guest (anonymous) sign-in

Create a session with no e-mail or password — for a user who hasn't signed up yet. Call /signup with both fields empty:

curl -X POST "https://<ref>.kethosbase.com/auth/v1/signup" \
  -H "Authorization: Bearer kbp_<publishable-key>" \
  -H "Content-Type: application/json" \
  -d '{}'

The response is the same session as any other sign-in, and the access token carries is_anonymous: true so your policies can tell guests apart:

create policy "guests read only" on notes for select
  using ( true );
create policy "members write" on notes for insert
  with check ( not (auth.jwt() ->> 'is_anonymous')::boolean );

A guest becomes a permanent account by setting an e-mail or password with updateUser, or by linking a social identity — the same user id carries over. Guest creation is rate-limited per IP like e-mail sign-up.

Social sign-in (OAuth)

"Continue with Google / GitHub / …". Start the flow by sending the user's browser to GET /auth/v1/authorize; the provider sends them back to GET /auth/v1/callback, which mints a session and redirects to your app with the session in the fragment — the same implicit hand-off as the magic link:

GET /auth/v1/authorize?provider=google&redirect_to=https://app.example.com/welcome
  → 302 to the provider's consent screen
  → provider returns to /auth/v1/callback
  → 302 Location: https://app.example.com/welcome#access_token=…&refresh_token=…

An existing identity for that provider signs the user straight in; otherwise the platform links by e-mail (matching or creating an account) and records the identity. The redirect_to uses the same allowlist as the magic link.

Providers are operator configuration — no provider is enabled until its credentials are supplied. See Social sign-in (OAuth) for the setup guide and the callback URL to register. The callback supports PKCE the same way the magic link does.

Multi-factor auth (TOTP)

Add an authenticator-app second factor. All three calls use the signed-in user's access token; verifying a challenge returns a new session at assurance level aal2 (in the token's aal claim).

# enroll — returns the secret once, plus an otpauth:// URI to show as a QR
POST /auth/v1/factors
{ "factor_type": "totp", "friendly_name": "Authenticator" }
→ { "id": "…", "type": "totp", "totp": { "secret": "…", "uri": "otpauth://…", "qr_code": "otpauth://…" } }

# challenge, then verify a 6-digit code
POST /auth/v1/factors/<id>/challenge          → { "id": "<challenge>", "expires_at": … }
POST /auth/v1/factors/<id>/verify
{ "challenge_id": "<challenge>", "code": "123456" }   → a new aal2 session

DELETE /auth/v1/factors/<id>                   # unenroll

Codes are standard TOTP (SHA-1, 6 digits, 30-second period). Enrolled factors appear on /auth/v1/user as factors.

Passkeys (WebAuthn)

A passkey is a phishing-resistant credential: the authenticator signs the server challenge together with the origin it is talking to, so a credential minted for your site cannot be replayed from a look-alike page. Kethosbase offers passkeys two ways — as a stronger second factor (like TOTP, reaching aal2) and as passwordless sign-in.

Enable it first. Because WebAuthn is bound to your app's own domain, set the relying-party rpId and allowed origins for the project under Dashboard → Auth → Passkeys. Until you do, the passkey endpoints return 422 webauthn_not_configured; TOTP and password login are unaffected.

As a second factor, a passkey uses the same three factor endpoints as TOTP — factor_type is "webauthn", and the challenge response carries the WebAuthn options for the browser:

POST /auth/v1/factors            { "factor_type": "webauthn", "friendly_name": "My Passkey" }
POST /auth/v1/factors/<id>/challenge   → { "id": "<challenge>", "credential_creation_options": { … } }
# pass the options to navigator.credentials.create(), then:
POST /auth/v1/factors/<id>/verify      { "challenge_id": "<challenge>", "credential": <attestation> }
→ a new aal2 session
# a verified factor returns credential_request_options on challenge; verifying an
# assertion the same way steps a session up to aal2.

Passwordless sign-in lets a returning user authenticate with a passkey alone (a discoverable credential registered earlier). It is two unauthenticated calls; the options call takes no e-mail and returns an empty allowCredentials, so it never reveals whether an account exists:

POST /auth/v1/webauthn/authenticate/options   → { "id": "<challenge>", "credential_request_options": { … } }
# pass the options to navigator.credentials.get(), then:
POST /auth/v1/webauthn/authenticate           { "challenge_id": "<challenge>", "credential": <assertion> }
→ an aal1 session for the resolved user

Passwordless establishes a single factor (aal1), so a project that also requires MFA still asks for a second factor. User verification is required on every ceremony, and a passkey registered for one project can never authenticate against another.

Manage the signed-in account

A signed-in user updates their own account with PUT /auth/v1/user — any of e-mail, password, or profile metadata. The call uses the user's access token; send only the fields you are changing:

curl -X PUT "https://<ref>.kethosbase.com/auth/v1/user" \
  -H "Authorization: Bearer <access-token>" \
  -H "Content-Type: application/json" \
  -d '{ "password": "a-new-strong-password", "data": { "display_name": "Alice" } }'
Reauthentication + confirmed e-mail change. Changing your password or e-mail requires re-sending your current_password in the same request — omitting it is 400 reauthentication_required and a wrong one is 401. A password-less (social-only) account is exempt until it adds a password. An e-mail change is staged: a confirmation link goes to the new address, and the change — and the guest→permanent switch — apply only when that link is redeemed (GET /auth/v1/verify?type=email_change). Where no mailer is configured the change applies immediately.

Linked identities

A user can attach more than one social identity to a single account. GET /auth/v1/user lists them under identities. To add one, the signed-in user requests a link URL and the browser follows it through the normal provider flow:

# get a provider link URL for the signed-in user
GET /auth/v1/user/identities/authorize?provider=github&redirect_to=…
  -H "Authorization: Bearer <access-token>"
→ { "url": "https://<ref>.kethosbase.com/auth/v1/authorize?…" }

# remove one of the caller's own identities
DELETE /auth/v1/user/identities/<identityId>
  -H "Authorization: Bearer <access-token>"

Linking an identity already bound to another account is refused rather than moved. Unlinking refuses to remove a user's last sign-in method (no other identity and no password), so a user can never lock themselves out. A social-only account can add a password through updateUser.

Using the access token

Send the user's access token instead of the publishable key; the request runs as the authenticated role with your RLS policies applied:

curl "https://<ref>.kethosbase.com/rest/v1/todos" \
  -H "Authorization: Bearer <access-token>"

RLS against the signed-in user

Policies read the JWT claims; auth.uid() returns the signed-in user's id:

create table todos (
  id      bigint generated always as identity primary key,
  owner   uuid not null default auth.uid(),
  task    text not null
);

grant select, insert on todos to p<ref>_authed;
alter table todos enable row level security;
create policy own_rows on todos
  using (owner = auth.uid())
  with check (owner = auth.uid());

Verifying tokens locally (JWKS)

Access tokens are ES256 JWTs signed with the project's own key. To verify one offline — in an edge function or your backend — fetch the project's public key from its JWKS endpoint and check the signature yourself:

GET https://<ref>.kethosbase.com/auth/v1/.well-known/jwks.json

→ { "keys": [ { "kty": "EC", "crv": "P-256", "alg": "ES256", "use": "sig", "kid": "…", "x": "…", "y": "…" } ] }

The endpoint is public and unauthenticated — it exposes only the public key, never private material. Each access token's header carries a kid (an RFC 7638 thumbprint of the key) so a verifier can select the matching JWK. The project runs one signing key today; the kid + JWKS make the contract ready for future key rotation.

Sign out

curl -X POST "https://<ref>.kethosbase.com/auth/v1/logout" \
  -H "Authorization: Bearer kbp_<publishable-key>" \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "kbr_…"}'

Always answers 204; the refresh token stops working. Existing access tokens expire naturally within the hour.

Sign out everywhere. Send "scope": "global" (in the body or as ?scope=global) with the user's access token to revoke every live refresh token for that user — "sign out of all devices". No session can be refreshed afterwards; already-issued access tokens still expire naturally within the hour.

curl -X POST "https://<ref>.kethosbase.com/auth/v1/logout?scope=global" \
  -H "Authorization: Bearer <access-token>"