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.
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.
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:
- Site URL — the default landing place when a request
omits
redirect_to. - Redirect URLs — the additional permitted targets. A
target matches when its scheme and host equal an entry's and its path is at
or under the entry's; custom mobile deep-link schemes match the same way,
script schemes (
javascript:,data:) never do.
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.
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.
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" } }'
- password — length- and breach-checked, then re-hashed.
Requires your
current_passwordin the same request (see below). Existing access tokens keep working until they expire within the hour. - email — must be unique (a duplicate is
409) and also requirescurrent_password; the new address is confirmed out of band (see below), and confirming it clearsis_anonymous, turning a guest into a permanent account. - data — stored as
user_metadata(end-user-editable profile JSON); it is never placed in the access token.
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>"