Help & integration guide
How to sign in, use OAuth, call the API, and manage sessions on this account service.
Overview
Central login, OAuth2-style authorization code flow, bearer-token API, profile pictures, friends, site email relay, email verification, password change, password reset, and SSH public-key sign-in. Connected sites never receive the user email address.
Sign in & accounts
Register
Visit /register. The first account becomes admin. Usernames are stored in lowercase; the form converts capitals as you type. Passwords must be at least 8 characters. A verification email is sent when SMTP is configured.
Sign in
Visit /login and submit your username or email and password in one field. Matching is case-insensitive for both username and email. After you continue, passkey sign-in is offered when your account has registered passkeys. If the passkey prompt does not complete, the page shows an error so you can retry or use a password. Legacy usernames with capital letters are converted to lowercase on sign-in. On success you receive the account_session cookie and are redirected to / or a safe return_to path (must start with /).
Passkeys
Signed-in users manage passkeys at /account?tab=passkeys. You can register this device, rename or remove passkeys, and optionally enable passkey-only login. That blocks password sign-in. A passkey or a registered SSH key can still sign in, and either one can turn the setting off.
SSH keys
Signed-in users register OpenSSH public keys at /account?tab=ssh-keys. Paste a single .pub line (ssh-ed25519, ssh-rsa of at least 2048 bits, or ecdsa-sha2-nistp256/384/521) plus a label. Private keys are rejected. The list shows the SHA256 fingerprint, label, and the time the key was added.
Sign-in is a challenge-response. Use the terminal script, or open /login and press the small ssh button in the corner of the sign-in card. Copy the echo -n command, then paste the signature back. A registered SSH key is stronger than a passkey: it can sign in while passkey-only login is on, and the same kind of signature can confirm a password change, an email change, or the passkey-only toggle. Password login stays blocked.
php web/scripts/ssh-login.php \
--base https://auth.timfalken.com \
--user annie \
--key ~/.ssh/id_ed25519 \
--return-to /
The same steps by hand: request a challenge, sign those exact bytes with no trailing newline, then verify. The namespace is timfalken-account.
curl -sS -X POST https://auth.timfalken.com/api/ssh-login/challenge \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
-H 'User-Agent: annie-ssh-login/1.0' \
-d '{"identifier":"annie"}'
echo -n 'CHALLENGE' | ssh-keygen -Y sign -n timfalken-account -f ~/.ssh/id_ed25519
curl -sS -X POST https://auth.timfalken.com/api/ssh-login/verify \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
-H 'User-Agent: annie-ssh-login/1.0' \
-d '{"identifier":"annie","challenge_id":"...","public_key":"ssh-ed25519 AAAA...","signature":"-----BEGIN SSH SIGNATURE-----...","return_to":"/"}'
The verify response includes session_token (send it as the account_session cookie) and redeem_path. Opening the redeem path in a browser sets the cookie and continues to return_to, including the OAuth consent page. Consent does not ask for a second signature. Unknown keys and bad signatures return the same error. Challenges are single-use. A login challenge cannot confirm an account change, and a step-up challenge cannot sign in.
Change password or email
On /account?tab=password, changing a password requires the current password plus an SSH key, a passkey, or an emailed one-time code. Other sessions are signed out. On /account?tab=email, confirm with an SSH key, a passkey, or the current password. Passkey-only accounts must use an SSH key or a passkey. The new address is confirmed from a link before it replaces the current one.
Blocked accounts
An admin can block a user from the Users tab. A blocked account cannot sign in with a password, passkey, SSH key, or OAuth flow, and existing sessions and bearer tokens stop working. The sign-in page explains that the account is blocked.
Sign out
POST to /logout with a CSRF token. This clears the cookie and revokes the server session.
OAuth for connected sites
This service implements an OAuth2-style authorization code flow. Each site is registered by domain name; the domain is the client_id.
1. Register your site (admin)
An admin adds your domain and callback paths at /admin, e.g. domain timfalken.com with path /auth/callback. Copy the client_secret immediately — it is only shown once.
2. Redirect the user to authorize
GET https://auth.timfalken.com/oauth/authorize
?client_id=timfalken.com
&redirect_uri=https://blog.timfalken.com/auth/callback
&response_type=code
&state=RANDOM_CSRF_TOKEN
stateis required — verify it matches on callback.- Query strings on
redirect_uriare stripped; register the path only. - Subdomains of the registered domain are allowed.
- Non-default ports (e.g.
:1234) must be registered under Allowed ports in the admin panel. - Unauthenticated users are sent to
/loginand returned here after sign-in.
3. Exchange the code (server-side)
In your callback handler (e.g. /auth/callback), exchange the code on the server. Use the same redirect_uri as in step 2.
POST https://auth.timfalken.com/oauth/token
Content-Type: application/x-www-form-urlencoded
Accept: application/json
User-Agent: MySiteAccountClient/1.0
grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=https://blog.timfalken.com/auth/callback
&client_id=timfalken.com
&client_secret=YOUR_SECRET
Authorization codes expire in 5 minutes and are single-use. After approval, the browser is redirected to your callback:
302 Location: https://blog.timfalken.com/auth/callback?code=AUTHORIZATION_CODE&state=CLIENT_STATE
Successful token exchange response:
{
"access_token": "a1b2c3d4e5f6789012345678abcdef01",
"token_type": "Bearer",
"expires_in": 86400,
"user": {
"id": 1,
"username": "tim"
}
}
The user object is only id and username. The email address is never included.
Error responses use {"error":"..."} with HTTP 400, for example {"error":"Invalid client credentials."}.
Connected site requirements
Every site that integrates with this account service must implement the following for its own users.
User data export (required)
Provide a clearly labelled control in user settings — for example Export my data or Download my data (JSON). When the signed-in user clicks it, they must immediately receive a complete JSON export of all personal data your site stores for them.
- Scope: profile/auth fields (from
GET /api/meor your stored OAuth user), preferences, and any user-created content in your database. - Format:
application/json. - Delivery: show the raw JSON in a modal, or trigger a
.jsonfile download (e.g.my-data-export.json). - Access: only the authenticated user may export their own data — no email request, no admin approval.
- Implementation: add a server-side route on your site (e.g.
GET /settings/export) that aggregates and returns the JSON on click.
{
"exported_at": "2026-06-05T12:00:00Z",
"account": {
"id": 1,
"username": "tim",
"email_verified": true
},
"preferences": { "...": "..." },
"content": [ "... user-owned records ..." ]
}
This export covers data on your connected site. The account service itself does not store site-specific content beyond authentication.
Server-to-server HTTP client
Connected sites call /oauth/token and /api/* from PHP or another backend HTTP client. Send these headers on every account request:
User-Agent: MySiteAccountClient/1.0— identify your integration (site name + version).Accept: application/json— receive JSON success and error payloads.Content-Type: application/x-www-form-urlencoded— forPOST /oauth/token.Authorization: Bearer <access_token>— for/api/me,/api/friends,/api/session/extend, and/api/logout.POST /api/emaildoes not use the user bearer token. Sendclient_idandclient_secretin the JSON body.
Store the access_token in your site session after a successful token exchange. Parse the JSON body in your callback handler and map error fields to your login error UI.
API endpoints
User routes require Authorization: Bearer <access_token> from the token exchange. POST /api/email is the exception: it uses the site's client credentials.
GET /api/me
Validate the token and return the current user.
GET https://auth.timfalken.com/api/me
Authorization: Bearer ACCESS_TOKEN
Accept: application/json
User-Agent: MySiteAccountClient/1.0
POST /api/session/extend
Reset the session expiry to another full 24 hours. Call this during active use before the token expires.
POST https://auth.timfalken.com/api/session/extend
Authorization: Bearer ACCESS_TOKEN
Accept: application/json
User-Agent: MySiteAccountClient/1.0
{
"expires_at": "2026-06-05 19:00:00",
"expires_in": 86400
}
POST /api/logout
Revoke the bearer token.
{"ok": true}
GET /api/me — response body
{
"user": {
"id": 1,
"username": "tim",
"is_admin": false,
"email_verified": true,
"passkey_only": false,
"avatar_url": "https://auth.timfalken.com/avatar/1?v=1"
}
}
The token exchange user object contains id, username, and avatar_url. /api/me also adds is_admin, email_verified, and passkey_only. avatar_url is null when the user has no picture. The picture is a 512×512 square. When it changes, v changes and the previous file is deleted. Neither response includes email.
GET /api/friends
Friends of the authenticated user. Each entry is a stable id, username, and avatar_url.
GET https://auth.timfalken.com/api/friends
Authorization: Bearer ACCESS_TOKEN
Accept: application/json
User-Agent: MySiteAccountClient/1.0
{
"friends": [
{"id": 2, "username": "ada", "avatar_url": "https://auth.timfalken.com/avatar/2?v=3"}
]
}
Friends
On /account?tab=friends a signed-in user can create an invite link. The link is single-use and expires after 7 days. Opening it while signed out goes to login and then back to the invite. The other person sees who invited them and can accept or decline. Accept adds the friendship for both accounts. Either person can remove the friend, which removes both sides. A link to yourself, an expired link, a revoked link, or a link that was already accepted or declined is explained on the page and does not add a friend.
Email a user from your site
Connected sites do not receive the user's email address. To send mail, call POST /api/email with the client domain credentials (client_id and client_secret), not the user's access token. That lets you email someone later, when they are offline.
POST https://auth.timfalken.com/api/email
Content-Type: application/json
Accept: application/json
User-Agent: MySiteAccountClient/1.0
{
"client_id": "timfalken.com",
"client_secret": "YOUR_SECRET",
"username": "ada",
"subject": "Your turn",
"html": "<p>Hello <strong>Ada</strong>.</p>"
}
Success is {"ok": true}. Errors add a code:
invalid_client(401) — client id or secret is wrong.invalid_input(400) — missing fields, subject too long, HTML too large, or nothing left after sanitizing.unknown_user(404) — no account with that username.never_signed_in(403) — that user has never approved sign-in for this domain.email_opted_out(403) — the user turned off email from this site.rate_limited(429) — more than 5 messages to that user, or 30 from this site, in the last hour.delivery_failed(502) — SMTP could not send the message.
HTML is limited to basic formatting. Scripts and similar tags are removed. Every message ends with a footer: Sent by timfalken.com via auth.timfalken.com (your client domain and this auth host) and a link to email preferences.
On the first sign-in to a site, the consent screen shows a checked box, “timfalken.com is allowed to send me email”, with the subtitle “Your email address is never shared with timfalken.com”. Allow sign-in stores that choice. If a preference already exists, the box is hidden. Users change it later under Email preferences, which only lists sites they have signed in to. Clicking a site shows the subjects and times of the last 10 emails that site sent. Bodies are not kept.
Response examples
Every route below lists the status code, content type, and example payload or redirect target returned by this service.
Conventions
- JSON errors use
{"error":"message"}. Some API errors also include"code"(for exampleemail_opted_out). Content type isapplication/json; charset=utf-8. - Browser form flows return 302 redirects with flash messages in query params (
message,error). - OAuth token and
/api/*routes return JSON bodies on success and failure.
GET /
Home page; shows sign-in state and links.
GET /help
Human-readable integration and usage guide.
Location: https://auth.timfalken.com/help.json
GET /help.json
Machine-readable JSON specification of all auth functionality.
{
"schema_version": "1.7",
"service": "account-auth",
"endpoints": "[...]"
}
GET /login
Sign-in form. Supports return_to query param (relative path only).
POST /login
Location: /
Sets cookie account_session.
Location: /oauth/authorize?...
Location: /login?error=Invalid+username%2Femail+or+password.
Location: /login?error=Invalid+session.+Please+try+again.
POST /api/ssh-login/challenge
Start SSH public-key sign-in. identifier is a username or email. The response does not reveal whether the account or a key exists. Rate limited per IP and per identifier. No CSRF token.
{
"challenge_id": "64 hex chars",
"challenge": "64 hex chars",
"namespace": "timfalken-account",
"hash_algorithms": [
"sha512",
"sha256"
],
"expires_in": 300,
"sign_command": "echo -n 'CHALLENGE' | ssh-keygen -Y sign -n timfalken-account -f ~/.ssh/id_ed25519"
}
{
"error": "Enter your username or email.",
"code": "invalid_identifier"
}
{
"error": "Too many attempts. Try again later.",
"code": "rate_limited"
}
POST /api/ssh-login/verify
Verify an SSHSIG signature over the challenge bytes. public_key is one OpenSSH .pub line already registered on the account. signature is the armored output of ssh-keygen -Y sign -n timfalken-account. On success, session_token is the account_session cookie value and login_code can be opened at redeem_path to set that cookie in a browser and continue OAuth. Passkey-only does not block this. Unknown key, bad signature, and unknown account share one error. A step-up challenge cannot be reused here.
{
"ok": true,
"session_token": "hex session token",
"cookie_name": "account_session",
"expires_in": 86400,
"login_code": "64 hex chars",
"login_code_expires_in": 300,
"redeem_path": "/login/ssh?code=LOGIN_CODE",
"user": {
"id": 2,
"username": "annie",
"avatar_url": null
}
}
{
"error": "SSH sign-in failed.",
"code": "ssh_signin_failed"
}
{
"error": "This account is blocked. Contact an administrator.",
"code": "blocked"
}
{
"error": "Too many attempts. Try again later.",
"code": "rate_limited"
}
GET /login/ssh
Redeem a one-time SSH login code. Sets the account_session cookie and redirects to the return_to stored at verify time, or to /.
Location: /
Sets cookie account_session.
Location: /oauth/authorize?...
Sets cookie account_session.
Location: /login?error=SSH+sign-in+failed.
GET /register
Registration form.
POST /register
Creates an account. A profile picture is optional. If avatar is sent, avatar_cropped must be 1 because the browser crop step is required. The stored picture is always a 512×512 square. URL-encoded requests without a file still work.
Location: /admin?tab=domains&message=Account+created.+Check+your+email...
Sets cookie account_session.
Location: /?message=Account+created.+Check+your+email...
Sets cookie account_session.
Location: /register?error=Password+must+be+at+least+8+characters.
POST /logout
Location: /login?message=Signed+out.
GET /verify-email
Email verification link handler.
Location: /oauth/authorize?client_id=...
Location: /login?return_to=%2Foauth%2Fauthorize%3F...&message=Email+verified.+You+can+now+continue.
Location: /login?message=Email+verified.+You+can+now+continue.
Location: /login?error=Verification+link+is+invalid+or+has+expired.
GET /forgot-password
Request password reset form.
POST /forgot-password
Always shows generic success message (no email enumeration).
Location: /forgot-password?message=If+an+account+with+a+verified+email+matches%2C+a+reset+link+has+been+sent.
GET /reset-password
Password reset form when token is valid.
Location: /login?error=Password+reset+link+is+invalid+or+has+expired.
POST /reset-password
Location: /login?message=Password+updated.+Sign+in+with+your+new+password.
Location: /reset-password?token=...&error=Passwords+do+not+match.
POST /resend-verification
Resend verification email for signed-in unverified user.
Location: /?message=Verification+email+sent.
Location: /?message=Your+email+is+already+verified.
GET /account
Signed-in account page for the profile picture, passkeys, SSH public keys, password, email change, email preferences, and friends. Choosing or changing a profile picture always opens a square crop step before it is saved. The stored image is exactly 512×512 and the previous file is deleted. The SSH keys tab lists fingerprint, label, and added time, and accepts OpenSSH .pub lines only. Email preferences lists only domains the user has signed in to. Each has an allow-email toggle (default allowed). Clicking a domain opens the subjects and times of the last 10 emails that site sent. Friends lists mutual friends and single-use invite links.
Location: /login?error=Sign+in+to+manage+your+account.
POST /account/avatar
Replace the signed-in user profile picture. avatar_cropped must be 1; the crop step is required at signup and when changing the picture. The previous file is deleted immediately. The stored image is always a 512×512 square. Passkey-only and SSH sign-in can use this route.
Location: /account?message=Profile+picture+updated.
Location: /account?error=Crop+the+picture+before+saving+it.
Location: /login?error=Sign+in+to+manage+your+account.
POST /account/avatar/clear
Remove the signed-in user profile picture and delete the file.
Location: /account?message=Profile+picture+removed.
Location: /login?error=Sign+in+to+manage+your+account.
GET /avatar/{userId}
Profile picture bytes for a user id. The image is always exactly 512×512. No email or username is returned. Unknown users and users without a picture both 404. v is the avatar version from avatar_url; a matching v is cached for a long time. Replacing the picture deletes the old file and increments v.
{
"error": "Not found."
}
POST /account/ssh-keys
Register one OpenSSH public key for the signed-in user. label is 1-64 characters. public_key is a single .pub line. Private key material is rejected. The same fingerprint cannot be registered twice.
Location: /account?tab=ssh-keys&message=SSH+key+added.
Location: /account?tab=ssh-keys&error=Paste+an+OpenSSH+public+key+(.pub).+Private+keys+are+not+accepted.
Location: /account?tab=ssh-keys&error=Invalid+session.
POST /account/ssh-keys/delete
Remove one SSH public key owned by the signed-in user.
Location: /account?tab=ssh-keys&message=SSH+key+removed.
Location: /account?tab=ssh-keys&error=That+SSH+key+was+not+found.
POST /account/password/code
Email an 8-digit one-time code to the verified address. Valid for password_change_code_ttl_seconds.
Location: /account?tab=password&message=A+code+was+sent+to+your+email+address.
Location: /account?tab=password&error=Verify+your+email+before+using+an+email+code.
POST /account/password
Change password using the current password plus an emailed code. factor=ssh or factor=passkey is rejected here; those confirmations use their finish endpoints. Other sessions are revoked.
Location: /account?tab=password&message=Password+updated.+Other+sessions+were+signed+out.
Location: /account?tab=password&error=Current+password+is+incorrect.
POST /account/password/passkey/begin
Start a passkey assertion used as the second factor for a password change.
POST /account/password/passkey/finish
Finish the passkey assertion and change the password. Other sessions are revoked.
{
"ok": true,
"redirect": "/account?tab=password&message=Password+updated.+Other+sessions+were+signed+out."
}
POST /account/email
Request an email change confirmed with the current password. Rejected when passkey-only login is enabled, and when factor is ssh or passkey. The address does not change until GET /confirm-email-change.
Location: /account?tab=email&message=Check+the+new+inbox...
Location: /account?tab=email&error=Passkey-only+login+is+enabled.+Confirm+the+email+change+with+a+passkey+or+an+SSH+key.
Location: /account?tab=email&error=Email+is+already+registered.
POST /account/email/passkey/begin
Start a passkey assertion used to confirm an email change.
POST /account/email/passkey/finish
Finish the passkey assertion and send the new-address confirmation link.
{
"ok": true,
"redirect": "/account?tab=email&message=Check+the+new+inbox..."
}
POST /account/ssh-confirm/challenge
Create an SSHSIG challenge for a signed-in step-up. purpose is password_change, email_change, or passkey_only. The challenge is bound to that user and cannot be used for POST /api/ssh-login/verify. Requires a registered SSH key.
{
"challenge_id": "64 hex chars",
"challenge": "64 hex chars",
"namespace": "timfalken-account",
"sign_command": "echo -n 'CHALLENGE' | ssh-keygen -Y sign -n timfalken-account -f ~/.ssh/id_ed25519",
"expires_in": 300
}
{
"error": "Register an SSH key before using this option."
}
POST /account/password/ssh/finish
Change the password after the current password checks out and an SSH signature satisfies the same step-up a passkey would. Other sessions are revoked.
{
"ok": true,
"redirect": "/account?tab=password&message=Password+updated.+Other+sessions+were+signed+out."
}
{
"error": "SSH confirmation failed."
}
POST /account/email/ssh/finish
Request an email change after an SSH signature. Allowed for passkey-only accounts. The address does not change until GET /confirm-email-change.
{
"ok": true,
"redirect": "/account?tab=email&message=Check+the+new+inbox..."
}
{
"error": "SSH confirmation failed."
}
POST /webauthn/passkey-only/ssh/finish
Turn passkey-only login on or off with an SSH signature. Enabling requires at least one passkey or one SSH key. A passkey assertion on POST /webauthn/passkey-only/finish still works and is not required when an SSH key is used.
{
"ok": true,
"passkey_only": true
}
{
"error": "SSH confirmation failed."
}
GET /confirm-email-change
Confirm a pending email change. Marks the new address verified and leaves the previous address active until this succeeds.
Location: /account?tab=email&message=Email+address+updated.
Location: /login?message=Email+address+updated.
Location: /login?error=Email+change+link+is+invalid+or+has+expired.
GET /oauth/authorize
OAuth authorization; shows consent when authenticated.
Location: /login?return_to=%2Foauth%2Fauthorize%3F...
{
"error": "client_id, redirect_uri, and state are required."
}
{
"error": "Unknown client_id."
}
POST /oauth/authorize
Approves sign-in. On the first approval for this user and domain, email_opt_in_presented=1 is sent and allow_site_email=1 stores that the site may email the user (omit allow_site_email to opt out). Later approvals do not change the stored preference.
Location: https://blog.timfalken.com/auth/callback?code=AUTHORIZATION_CODE&state=CLIENT_STATE
{
"redirect": "https://blog.timfalken.com/auth/callback?code=AUTHORIZATION_CODE&state=CLIENT_STATE"
}
{
"error": "Invalid CSRF token."
}
{
"error": "Authentication required."
}
POST /oauth/token
{
"access_token": "a1b2c3d4e5f6789012345678abcdef01",
"token_type": "Bearer",
"expires_in": 86400,
"user": {
"id": 1,
"username": "tim",
"avatar_url": "https://auth.timfalken.com/avatar/1?v=1"
}
}
{
"error": "Invalid client credentials."
}
{
"error": "Invalid or expired authorization code."
}
{
"error": "redirect_uri does not match the authorized value."
}
{
"error": "Unsupported grant_type."
}
GET /api/me
{
"user": {
"id": 1,
"username": "tim",
"is_admin": false,
"email_verified": true,
"passkey_only": false,
"avatar_url": "https://auth.timfalken.com/avatar/1?v=1"
}
}
{
"error": "Bearer token required."
}
{
"error": "Invalid or expired token."
}
POST /api/session/extend
Sliding session renewal; resets TTL to session_ttl_seconds.
{
"expires_at": "2026-06-05 19:00:00",
"expires_in": 86400
}
{
"error": "Bearer token required."
}
{
"error": "Invalid or expired token."
}
POST /api/logout
Revokes the bearer token server-side.
{
"ok": true
}
{
"error": "Bearer token required."
}
{
"error": "Invalid or expired token."
}
GET /api/friends
Friends of the user identified by the bearer token. Each friend is a stable id, username, and avatar_url. Email addresses are not included. avatar_url is null when that friend has no picture.
{
"friends": [
{
"id": 2,
"username": "ada",
"avatar_url": "https://auth.timfalken.com/avatar/2?v=3"
}
]
}
{
"error": "Bearer token required."
}
{
"error": "Invalid or expired token."
}
POST /api/email
Send HTML email to a user on behalf of the authenticated client domain. Authenticate with client_id and client_secret in the body (the same client credentials as POST /oauth/token). The user bearer token is not accepted. The address is never returned. A footer is always appended: "Sent by {client_id} via {auth host}" plus a link to /account?tab=email-preferences. HTML is sanitized (scripts, iframes, and javascript URLs removed). Subject max length and HTML byte size are in constants. Rate limits: email_relay_per_user_per_hour successful-or-attempted messages per user per domain, and email_relay_per_client_per_hour per domain.
{
"ok": true
}
{
"error": "username, subject, and html are required.",
"code": "invalid_input"
}
{
"error": "Subject is too long.",
"code": "invalid_input"
}
{
"error": "HTML body is too large.",
"code": "invalid_input"
}
{
"error": "HTML body is empty after sanitization.",
"code": "invalid_input"
}
{
"error": "Invalid client credentials.",
"code": "invalid_client"
}
{
"error": "This user has never signed in to this site.",
"code": "never_signed_in"
}
{
"error": "This user has turned off email from this site.",
"code": "email_opted_out"
}
{
"error": "Unknown user.",
"code": "unknown_user"
}
{
"error": "Too many emails to this user from this site. Try again later.",
"code": "rate_limited"
}
{
"error": "The email could not be sent.",
"code": "delivery_failed"
}
GET /friends/invite/{token}
Shows who sent a friend invite. Signed-out visitors are redirected to login and returned here. Accept and decline are POST /friends/invite/{token}/accept and /decline with csrf.
Location: /login?return_to=%2Ffriends%2Finvite%2FTOKEN
GET /admin
Admin UI for OAuth domains, SMTP settings, and users.
Location: /login?error=Admin+access+required.
POST /admin/domains
Location: /admin?tab=domains&message=Domain+registered.
POST /admin/smtp
Save SMTP settings; blank password keeps existing password.
Location: /admin?tab=smtp&message=SMTP+settings+saved.
POST /admin/smtp/test
Send test email using form values.
Location: /admin?tab=smtp&message=Test+email+sent+to+admin%40example.com.
POST /admin/users/block
Block sign-in and revoke sessions, bearer tokens, and unused authorization codes. Admins cannot block themselves or the last admin.
Location: /admin?tab=users&id=2&message=User+blocked.+They+can+no+longer+sign+in.
POST /admin/users/unblock
Allow a blocked user to sign in again. Previous sessions stay revoked.
Location: /admin?tab=users&id=2&message=User+unblocked.+They+can+sign+in+again.
POST /admin/users/reset-password
Email a 10-character random temporary password and revoke that user's sessions. Passkey-only login is turned off so the temporary password can be used.
Location: /admin?tab=users&id=2&message=Temporary+password+emailed.+Their+sessions+were+signed+out.
POST /admin/users/delete
Permanently delete the user and dependent passkeys, sessions, email tokens, and OAuth grants. Requires confirm=delete. Admins cannot delete themselves or the last admin.
Location: /admin?tab=users&message=User+deleted.
Sessions
- Web sessions use the
account_sessioncookie (session_type=web). They work for the account site and OAuth consent, not as API bearer tokens. - API sessions use bearer tokens (
session_type=oauth) from/oauth/token. They are locked to the host that started the OAuth flow. - Default TTL is 24 hours for both. Bearer tokens can be extended with
POST /api/session/extend. - Expired or invalid tokens return
401on API routes. - Password reset revokes all sessions for that user. Changing a password from
/accountrevokes the other sessions and keeps the current one. - Blocking a user revokes their sessions and bearer tokens immediately.
Email verification & password reset
- Verification link:
GET /verify-email?token=...(valid 24 hours). - Resend while signed in:
POST /resend-verification. - Forgot password:
/forgot-password— only verified emails receive a reset link. - Reset link:
GET /reset-password?token=...(valid 1 hour). - Signed-in password change can email an 8-digit code (valid 10 minutes) to a verified address.
- Email change confirmation:
GET /confirm-email-change?token=...(valid 24 hours). The previous address stays active until the link is opened. - Admins configure SMTP at
/admin?tab=smtpand can send a test email.
Admin
Admins manage users, OAuth domains, callback paths, client secrets, and SMTP at /admin.
- Register domains with at least one callback path.
- Regenerate client secrets from the domains tab; old secrets stop working immediately.
- SMTP: port 587 + TLS (STARTTLS) is typical; use 465 + SSL if your provider requires it.
- Users tab: list accounts, then block, unblock, email a temporary password, or permanently delete. You cannot block or delete yourself, and the last admin cannot be removed.
Machine-readable specification
For scripts, integrations, and AI agents, use the JSON spec (also advertised in the page <head> via rel="alternate" and the Link response header):
https://auth.timfalken.com/help.json
Clients that send Accept: application/json (without preferring HTML) are redirected from /help to /help.json automatically.
The JSON includes all endpoints, flows, constants, authentication modes, and integration checklist. It is the canonical structured reference; this page is the human-readable view.
View embedded JSON spec
{
"schema_version": "1.7",
"response_conventions": {
"json_content_type": "application/json; charset=utf-8",
"error_envelope": {
"error": "string",
"code": "optional string on API errors that need a stable machine-readable reason"
},
"redirect": "HTTP 302 with Location header; no JSON body",
"html": "HTTP 200 text/html page"
},
"service": "account-auth",
"title": "Account authentication service",
"description": "Central login, OAuth2-style authorization code flow, bearer-token API, profile pictures, friends, site email relay, email verification, password change, password reset, and SSH public-key sign-in. Connected sites never receive the user email address.",
"base_url": "https://auth.timfalken.com",
"documentation_urls": {
"human": "https://auth.timfalken.com/help",
"machine": "https://auth.timfalken.com/help.json"
},
"discovery": {
"alternate_link": {
"rel": "alternate",
"type": "application/json",
"href": "/help.json",
"location": "HTML head on GET /help"
},
"link_header": "Link: </help.json>; rel=\"alternate\"; type=\"application/json\"",
"accept_negotiation": "GET /help redirects to /help.json when Accept prefers application/json over text/html"
},
"requirements": {
"https_required_in_production": true,
"password_min_length": 8,
"redirect_uri_scheme": [
"https"
],
"username_lowercase": true,
"login_identifier": "Single field accepts username or email; both matched case-insensitively.",
"passkeys": {
"supported": true,
"management_path": "/account?tab=passkeys",
"passkey_only_default": false
},
"ssh_public_keys": {
"supported": true,
"formats": [
"ssh-ed25519",
"ssh-rsa",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521"
],
"private_keys_accepted": false,
"management_path": "/account?tab=ssh-keys",
"signature_namespace": "timfalken-account",
"trust_order": [
"ssh_key",
"passkey",
"password"
],
"passkey_only_blocks_password": true,
"passkey_only_blocks_login": false,
"ssh_satisfies_passkey_step_up": true,
"registration_allowed_when_passkey_only": true
}
},
"integrator_requirements": {
"user_data_export": {
"required": true,
"summary": "Every connected site must let signed-in users immediately download a complete JSON export of all personal data that site stores for them.",
"ui": {
"location": "User settings or account preferences page",
"control": "Clearly labelled button or link, e.g. \"Export my data\" or \"Download my data (JSON)\""
},
"scope": [
"Include all personal data your site stores for the requesting user.",
"Include account profile fields from GET /api/me or your stored OAuth user record.",
"Include preferences, activity, and any user-created content held in your database."
],
"format": "application/json",
"delivery": [
"Show the raw JSON in a modal or dialog, or",
"Trigger a file download as .json (Content-Disposition: attachment recommended)."
],
"constraints": [
"Export must be available immediately on click \u2014 no email request or admin approval.",
"Only the authenticated user may export their own data."
],
"suggested_route": "GET /settings/export or POST /settings/export-data (implement on your site)",
"example_filename": "my-data-export.json"
}
},
"constants": {
"session_ttl_seconds": 86400,
"oauth_code_ttl_seconds": 300,
"email_verify_ttl_seconds": 86400,
"password_reset_ttl_seconds": 3600,
"password_change_code_ttl_seconds": 600,
"web_cookie_name": "account_session",
"csrf_field": "csrf",
"webauthn_challenge_ttl_seconds": 300,
"webauthn_timeout_ms": 60000,
"friend_invite_ttl_seconds": 604800,
"email_relay_max_html_bytes": 100000,
"email_relay_max_subject_length": 200,
"email_relay_per_user_per_hour": 5,
"email_relay_per_client_per_hour": 30,
"email_relay_log_visible": 10,
"ssh_login_challenge_ttl_seconds": 300,
"ssh_login_code_ttl_seconds": 300,
"ssh_login_max_keys": 20,
"ssh_signature_namespace": "timfalken-account",
"avatar_edge": 512,
"avatar_max_bytes": 4194304
},
"authentication": {
"web_session": {
"type": "http_only_cookie",
"cookie_name": "account_session",
"set_on": [
"POST /login",
"POST /register",
"GET /login/ssh"
],
"cleared_on": [
"POST /logout"
],
"ttl_seconds": 86400,
"sliding_expiry": "Cookie max-age resets on login; use POST /api/session/extend for bearer tokens.",
"notes": [
"Web sessions use session_type=web. OAuth bearer tokens use session_type=oauth and are not accepted as web cookies.",
"Cookies are Secure when HTTPS is detected (including X-Forwarded-Proto: https).",
"Blocked accounts cannot sign in. Existing web sessions and bearer tokens stop working until an admin unblocks the account.",
"SSH public-key sign-in creates the same web session cookie. Passkey-only blocks password login only; a registered SSH key can still sign in."
]
},
"api_bearer": {
"type": "authorization_header",
"header": "Authorization: Bearer <access_token>",
"obtained_via": "POST /oauth/token after authorization code exchange",
"ttl_seconds": 86400,
"host_lock": "OAuth tokens are locked to the redirect_uri host that initiated the flow."
},
"oauth_client": {
"client_id": "Registered domain name (e.g. timfalken.com)",
"client_secret": "Shown once when domain is registered or regenerated in /admin",
"redirect_uri_rules": [
"Scheme must be https (http only when ACCOUNT_ALLOW_HTTP=1).",
"Query strings are stripped during validation; register the path only.",
"Fragments are rejected.",
"Host must belong to the registered domain (including subdomains).",
"Path must be registered as a callback path for that domain.",
"Non-default ports (not 80 for http or 443 for https) must be registered as allowed ports for that domain."
]
},
"csrf": {
"required_on": "All browser POST forms",
"field_name": "csrf",
"invalid_handling": "Redirect with error or HTTP 400 for OAuth authorize POST",
"not_required_on": [
"POST /api/ssh-login/challenge",
"POST /api/ssh-login/verify"
],
"notes": "SSH login APIs do not use the browser session cookie. Account SSH key add and remove forms still require csrf."
},
"server_side_http_client": {
"applies_to": [
"POST /oauth/token",
"GET /api/me",
"GET /api/friends",
"POST /api/session/extend",
"POST /api/logout",
"POST /api/email",
"POST /api/ssh-login/challenge",
"POST /api/ssh-login/verify"
],
"required_headers": {
"User-Agent": "A descriptive client name and version, e.g. MySiteAccountClient/1.0",
"Accept": "application/json"
},
"token_exchange_headers": {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"User-Agent": "Your client identifier"
},
"api_headers": {
"Authorization": "Bearer <access_token> on /api/me, /api/friends, /api/session/extend, and /api/logout",
"Accept": "application/json",
"User-Agent": "Your client identifier"
},
"email_relay_auth": {
"endpoint": "POST /api/email",
"scheme": "client_credentials",
"client_id": "Registered domain, same value as the OAuth client_id",
"client_secret": "The domain client secret, same value as POST /oauth/token",
"not_accepted": "The user access_token / Authorization Bearer header is not accepted. Mail can be sent later without the user being online.",
"content_type": "application/json or application/x-www-form-urlencoded"
},
"response_format": "JSON object; success and error payloads use Content-Type application/json",
"implementation_notes": [
"Perform token exchange in your OAuth callback handler on the server.",
"Use the same redirect_uri value as in the authorize redirect.",
"Store client_secret only in server-side configuration.",
"Parse the JSON body; map error fields to your site login error handling."
]
}
},
"flows": [
{
"id": "web_sign_in",
"title": "Sign in on the account site",
"audience": "human",
"steps": [
"Visit GET /login.",
"Submit POST /login with csrf, identifier (username or email, case-insensitive), and password.",
"Legacy usernames with uppercase letters are migrated to lowercase on successful sign-in.",
"After entering identifier, clients may offer passkey sign-in when the account has registered passkeys.",
"Passkey-only accounts reject password login until the setting is disabled at /account?tab=passkeys. A passkey or a registered SSH key can still sign in.",
"SSH public-key sign-in is a separate API and is stronger than a passkey. It works while passkey-only is enabled. Registering a key does not turn passkey-only off.",
"Blocked accounts reject password, passkey, and SSH sign-in with a clear blocked message. Existing sessions and bearer tokens are rejected.",
"On success, account_session cookie is set and the browser redirects to / or return_to.",
"Sign out via POST /logout (requires csrf)."
]
},
{
"id": "ssh_sign_in",
"title": "Sign in with an SSH public key",
"audience": "agent",
"steps": [
"While signed in, register one OpenSSH public key at /account?tab=ssh-keys. Paste the .pub line only. Supported types: ssh-ed25519, ssh-rsa (2048 bits or more), ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, ecdsa-sha2-nistp521.",
"POST /api/ssh-login/challenge with JSON identifier (username or email). The response is the same shape whether or not that account exists. It includes challenge_id, challenge (hex), and namespace timfalken-account.",
"Sign the exact challenge bytes (no trailing newline) with ssh-keygen -Y sign -n timfalken-account -f ~/.ssh/id_ed25519. The sign-in page shows echo -n 'CHALLENGE' | ssh-keygen -Y sign -n timfalken-account -f ~/.ssh/id_ed25519 with a copy button. The signature is an SSHSIG armored blob.",
"POST /api/ssh-login/verify with identifier, challenge_id, public_key, and signature, or paste the same values into the SSH key panel on GET /login. Optional return_to is a relative path such as /oauth/authorize?...",
"A successful verify returns session_token (the account_session cookie value) and a one-time login_code. Open redeem_path (/login/ssh?code=...) in a browser to set the cookie and continue to return_to, including the normal OAuth consent page. Consent stays an authorization step and does not ask for another SSH signature.",
"Passkey-only accounts can sign in with a registered SSH key. Password login stays blocked. A valid SSH signature also confirms password changes, email changes, and the passkey-only toggle.",
"Unknown keys, bad signatures, and unknown accounts all return the same SSH sign-in failed error. Challenges are single-use."
]
},
{
"id": "web_register",
"title": "Create an account",
"audience": "human",
"steps": [
"Visit GET /register.",
"Submit POST /register with csrf, username (stored lowercase), email, and password (min 8 characters).",
"The registration form converts uppercase letters to lowercase while typing.",
"Verification email is sent when SMTP is configured."
]
},
{
"id": "email_verification",
"title": "Verify email address",
"audience": "human",
"steps": [
"User receives email with link to GET /verify-email?token=...&return_to=... when continuing an OAuth sign-in.",
"Valid token marks email verified and redirects to return_to (or /login?return_to=...).",
"Signed-in unverified users can POST /resend-verification with the pending return_to preserved.",
"Password reset only works for verified emails."
]
},
{
"id": "password_reset",
"title": "Reset password",
"audience": "human",
"steps": [
"Visit GET /forgot-password and submit email via POST /forgot-password.",
"If a verified account matches, email contains GET /reset-password?token=...",
"Submit new password via POST /reset-password; all sessions for that user are revoked."
]
},
{
"id": "change_password",
"title": "Change password while signed in",
"audience": "human",
"steps": [
"Open GET /account?tab=password.",
"Submit the current password and a new password (min 8 characters).",
"Confirm with an SSH signature (POST /account/ssh-confirm/challenge purpose=password_change, then POST /account/password/ssh/finish), a passkey assertion (POST /account/password/passkey/begin and /finish), or a one-time code emailed to the verified address (POST /account/password/code, then POST /account/password with factor=email). The current password is still required.",
"Other sessions and bearer tokens are revoked. The current web session stays signed in."
]
},
{
"id": "change_email",
"title": "Change email while signed in",
"audience": "human",
"steps": [
"Open GET /account?tab=email.",
"Confirm with an SSH signature (POST /account/ssh-confirm/challenge purpose=email_change, then POST /account/email/ssh/finish), a passkey (POST /account/email/passkey/begin and /finish), or the current password (POST /account/email).",
"Passkey-only accounts must use an SSH key or a passkey. A password is not accepted.",
"The current address stays active. A confirmation link is sent to the new address (GET /confirm-email-change). Opening it switches the email and marks it verified.",
"Addresses already used by another account are rejected."
]
},
{
"id": "admin_users",
"title": "Admin user management",
"audience": "human",
"steps": [
"Admins open GET /admin?tab=users.",
"POST /admin/users/block prevents password, passkey, and OAuth sign-in and revokes sessions and unused authorization codes.",
"POST /admin/users/unblock allows sign-in again.",
"POST /admin/users/reset-password emails a short random temporary password, turns passkey-only off so that password can be used, and revokes that user's sessions. The next password change can be confirmed with an SSH key, a passkey, or an email code.",
"POST /admin/users/delete permanently removes the user after confirm=delete. Admins cannot block or delete themselves, and the last admin cannot be removed."
]
},
{
"id": "oauth_authorization_code",
"title": "OAuth authorization code (connected sites)",
"audience": "integrator",
"steps": [
"Redirect browser to GET /oauth/authorize?client_id=DOMAIN&redirect_uri=URL&response_type=code&state=CSRF",
"Unauthenticated users are sent to /login?return_to=/oauth/authorize?...",
"New users follow Create account with the same return_to; verification links include return_to.",
"After email verification, or after SSH sign-in with return_to, users return to the OAuth consent screen (or /login?return_to=... if signed out). Consent does not require a second SSH signature.",
"User approves on consent screen; POST /oauth/authorize issues redirect to redirect_uri?code=...&state=...",
"In your callback handler, POST /oauth/token with the same redirect_uri, User-Agent, and Accept application/json.",
"On the first approval for a domain, the consent screen includes a checked checkbox: the site is allowed to send email, and the address is never shared. That choice is stored immediately. Later approvals hide the checkbox.",
"Store access_token server-side and use Authorization Bearer on /api/me, /api/friends, /api/session/extend, and /api/logout. Do not read an email field; it is not returned."
]
},
{
"id": "server_side_http",
"title": "Server-to-server HTTP client",
"audience": "integrator",
"steps": [
"Set account base URL to your HTTPS auth host (e.g. https://auth.timfalken.com).",
"Send User-Agent identifying your site client (e.g. MySiteAccountClient/1.0).",
"Send Accept: application/json on POST /oauth/token and all /api/* requests.",
"POST /oauth/token with Content-Type application/x-www-form-urlencoded and the authorization code from the callback.",
"Parse the JSON response; on success store access_token and user in your site session.",
"Send Authorization: Bearer <access_token> on subsequent API calls with the same User-Agent and Accept headers."
]
},
{
"id": "session_extend",
"title": "Extend API session",
"audience": "integrator",
"steps": [
"Before access_token expires (default 24 hours), call POST /api/session/extend with Bearer token.",
"Response includes new expires_at and expires_in (86400).",
"Call periodically during active use; expired tokens return 401."
]
},
{
"id": "user_data_export",
"title": "User data export (required on connected sites)",
"audience": "integrator",
"steps": [
"Place a clearly labelled button in user settings, e.g. \"Export my data\" or \"Download my data (JSON)\".",
"On click, your server aggregates all personal data stored for the signed-in user.",
"Return application/json immediately \u2014 show it in a modal or offer a .json file download.",
"Include profile/auth fields (from GET /api/me or your session), preferences, and user-owned content.",
"Restrict access to the authenticated user only; do not require email requests or admin approval."
]
},
{
"id": "friends",
"title": "Friends",
"audience": "human",
"steps": [
"Signed-in users open GET /account?tab=friends and create an invite link.",
"Each link is single-use and expires after friend_invite_ttl_seconds (7 days). Revoking a link makes it invalid. Creating another link does not cancel older unused links.",
"Opening the link while signed out redirects to /login?return_to=/friends/invite/TOKEN and returns after sign-in.",
"The invitee sees who invited them and can Accept or Decline. Accept creates a mutual friendship. Decline consumes the link without adding a friend.",
"Opening your own link, an already-used or revoked link, or an expired link shows an explanation and does not add a friend. Already being friends does not consume the link.",
"Either friend can remove the other from GET /account?tab=friends. Removal deletes the friendship for both users.",
"Connected sites call GET /api/friends with the user bearer token. Each friend is id, username, and avatar_url."
]
},
{
"id": "site_email",
"title": "Email users without receiving their address",
"audience": "integrator",
"steps": [
"The user email address is not present on POST /oauth/token or GET /api/me.",
"The first time a user allows sign-in to your domain, consent stores whether your site may email them (checked by default). They can change it later at /account?tab=email-preferences.",
"POST /api/email with client_id, client_secret, username, subject, and html. Do not send the user bearer token.",
"Auth delivers the sanitized HTML to that user and appends a footer: who sent it (Sent by your.domain via the auth host) and a link to email preferences.",
"The call fails with a code when the user is unknown, has never signed in to your domain, has opted out, input is invalid, or the site is rate limited.",
"Subjects of sent mail are kept for the user (last 10 shown per site). Message bodies are not stored."
]
}
],
"endpoints": [
{
"method": "GET",
"path": "/",
"auth": "none",
"description": "Home page; shows sign-in state and links.",
"responses": [
{
"status": 200,
"content_type": "text/html",
"description": "Home page with sign-in links or signed-in state."
}
]
},
{
"method": "GET",
"path": "/help",
"auth": "none",
"description": "Human-readable integration and usage guide.",
"responses": [
{
"status": 200,
"content_type": "text/html",
"description": "Help page."
},
{
"status": 302,
"content_type": "redirect",
"location": "https://auth.timfalken.com/help.json",
"description": "When Accept prefers application/json."
}
]
},
{
"method": "GET",
"path": "/help.json",
"auth": "none",
"description": "Machine-readable JSON specification of all auth functionality.",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"schema_version": "1.7",
"service": "account-auth",
"endpoints": "[...]"
}
}
]
},
{
"method": "GET",
"path": "/login",
"auth": "none",
"description": "Sign-in form. Supports return_to query param (relative path only).",
"responses": [
{
"status": 200,
"content_type": "text/html",
"description": "Login form. Query params: error, message, return_to."
}
]
},
{
"method": "POST",
"path": "/login",
"auth": "none",
"content_type": "application/x-www-form-urlencoded",
"body": [
"csrf",
"identifier",
"password",
"return_to?"
],
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/",
"set_cookie": "account_session",
"description": "Success; session cookie set."
},
{
"status": 302,
"content_type": "redirect",
"location": "/oauth/authorize?...",
"description": "Success with safe return_to path."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?error=Invalid+username%2Femail+or+password.",
"description": "Invalid credentials."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?error=Invalid+session.+Please+try+again.",
"description": "Invalid CSRF token."
}
]
},
{
"method": "POST",
"path": "/api/ssh-login/challenge",
"auth": "none",
"content_type": "application/json",
"body": [
"identifier"
],
"description": "Start SSH public-key sign-in. identifier is a username or email. The response does not reveal whether the account or a key exists. Rate limited per IP and per identifier. No CSRF token.",
"request_example": {
"headers": {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "annie-ssh-login/1.0"
},
"body": {
"identifier": "annie"
}
},
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"challenge_id": "64 hex chars",
"challenge": "64 hex chars",
"namespace": "timfalken-account",
"hash_algorithms": [
"sha512",
"sha256"
],
"expires_in": 300,
"sign_command": "echo -n 'CHALLENGE' | ssh-keygen -Y sign -n timfalken-account -f ~/.ssh/id_ed25519"
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "Enter your username or email.",
"code": "invalid_identifier"
}
},
{
"status": 429,
"content_type": "application/json",
"body": {
"error": "Too many attempts. Try again later.",
"code": "rate_limited"
}
}
]
},
{
"method": "POST",
"path": "/api/ssh-login/verify",
"auth": "none",
"content_type": "application/json",
"body": [
"identifier",
"challenge_id",
"public_key",
"signature",
"return_to?"
],
"description": "Verify an SSHSIG signature over the challenge bytes. public_key is one OpenSSH .pub line already registered on the account. signature is the armored output of ssh-keygen -Y sign -n timfalken-account. On success, session_token is the account_session cookie value and login_code can be opened at redeem_path to set that cookie in a browser and continue OAuth. Passkey-only does not block this. Unknown key, bad signature, and unknown account share one error. A step-up challenge cannot be reused here.",
"request_example": {
"headers": {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "annie-ssh-login/1.0"
},
"body": {
"identifier": "annie",
"challenge_id": "CHALLENGE_ID",
"public_key": "ssh-ed25519 AAAA... annie",
"signature": "-----BEGIN SSH SIGNATURE-----\n...\n-----END SSH SIGNATURE-----",
"return_to": "/oauth/authorize?client_id=timfalken.com&redirect_uri=https%3A%2F%2Fblog.timfalken.com%2Fauth%2Fcallback&response_type=code&state=RANDOM"
}
},
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"ok": true,
"session_token": "hex session token",
"cookie_name": "account_session",
"expires_in": 86400,
"login_code": "64 hex chars",
"login_code_expires_in": 300,
"redeem_path": "/login/ssh?code=LOGIN_CODE",
"user": {
"id": 2,
"username": "annie",
"avatar_url": null
}
}
},
{
"status": 401,
"content_type": "application/json",
"body": {
"error": "SSH sign-in failed.",
"code": "ssh_signin_failed"
}
},
{
"status": 403,
"content_type": "application/json",
"body": {
"error": "This account is blocked. Contact an administrator.",
"code": "blocked"
}
},
{
"status": 429,
"content_type": "application/json",
"body": {
"error": "Too many attempts. Try again later.",
"code": "rate_limited"
}
}
]
},
{
"method": "GET",
"path": "/login/ssh",
"auth": "none",
"query": [
"code"
],
"description": "Redeem a one-time SSH login code. Sets the account_session cookie and redirects to the return_to stored at verify time, or to /.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/",
"set_cookie": "account_session",
"description": "Code accepted."
},
{
"status": 302,
"content_type": "redirect",
"location": "/oauth/authorize?...",
"set_cookie": "account_session",
"description": "Code accepted with return_to."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?error=SSH+sign-in+failed.",
"description": "Missing, used, or expired code."
}
]
},
{
"method": "GET",
"path": "/register",
"auth": "none",
"description": "Registration form.",
"responses": [
{
"status": 200,
"content_type": "text/html",
"description": "Registration form."
}
]
},
{
"method": "POST",
"path": "/register",
"auth": "none",
"content_type": "multipart/form-data",
"body": [
"csrf",
"username",
"email",
"password",
"avatar?",
"avatar_cropped?"
],
"description": "Creates an account. A profile picture is optional. If avatar is sent, avatar_cropped must be 1 because the browser crop step is required. The stored picture is always a 512\u00d7512 square. URL-encoded requests without a file still work.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/admin?tab=domains&message=Account+created.+Check+your+email...",
"set_cookie": "account_session",
"description": "First user (admin) created."
},
{
"status": 302,
"content_type": "redirect",
"location": "/?message=Account+created.+Check+your+email...",
"set_cookie": "account_session",
"description": "Subsequent user created."
},
{
"status": 302,
"content_type": "redirect",
"location": "/register?error=Password+must+be+at+least+8+characters.",
"description": "Validation error."
}
]
},
{
"method": "POST",
"path": "/logout",
"auth": "web_cookie",
"content_type": "application/x-www-form-urlencoded",
"body": [
"csrf"
],
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/login?message=Signed+out.",
"description": "Cookie cleared and session revoked."
}
]
},
{
"method": "GET",
"path": "/verify-email",
"auth": "none",
"query": [
"token"
],
"description": "Email verification link handler.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/oauth/authorize?client_id=...",
"description": "Valid token with return_to while still signed in."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?return_to=%2Foauth%2Fauthorize%3F...&message=Email+verified.+You+can+now+continue.",
"description": "Valid token with return_to after sign-out."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?message=Email+verified.+You+can+now+continue.",
"description": "Valid token without return_to."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?error=Verification+link+is+invalid+or+has+expired.",
"description": "Invalid or expired token."
}
]
},
{
"method": "GET",
"path": "/forgot-password",
"auth": "none",
"description": "Request password reset form.",
"responses": [
{
"status": 200,
"content_type": "text/html",
"description": "Forgot-password form."
}
]
},
{
"method": "POST",
"path": "/forgot-password",
"auth": "none",
"body": [
"csrf",
"email"
],
"description": "Always shows generic success message (no email enumeration).",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/forgot-password?message=If+an+account+with+a+verified+email+matches%2C+a+reset+link+has+been+sent.",
"description": "Always returned when request is accepted."
}
]
},
{
"method": "GET",
"path": "/reset-password",
"auth": "none",
"query": [
"token"
],
"description": "Password reset form when token is valid.",
"responses": [
{
"status": 200,
"content_type": "text/html",
"description": "Reset form with hidden token field."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?error=Password+reset+link+is+invalid+or+has+expired.",
"description": "Invalid token on GET."
}
]
},
{
"method": "POST",
"path": "/reset-password",
"auth": "none",
"body": [
"csrf",
"token",
"password",
"password_confirm"
],
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/login?message=Password+updated.+Sign+in+with+your+new+password.",
"description": "Password updated; all user sessions revoked."
},
{
"status": 302,
"content_type": "redirect",
"location": "/reset-password?token=...&error=Passwords+do+not+match.",
"description": "Password confirmation mismatch."
}
]
},
{
"method": "POST",
"path": "/resend-verification",
"auth": "web_cookie",
"body": [
"csrf"
],
"description": "Resend verification email for signed-in unverified user.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/?message=Verification+email+sent.",
"description": "Email sent."
},
{
"status": 302,
"content_type": "redirect",
"location": "/?message=Your+email+is+already+verified.",
"description": "Already verified."
}
]
},
{
"method": "GET",
"path": "/account",
"auth": "web_cookie",
"query": [
"tab=passkeys|ssh-keys|password|email|email-preferences|friends"
],
"description": "Signed-in account page for the profile picture, passkeys, SSH public keys, password, email change, email preferences, and friends. Choosing or changing a profile picture always opens a square crop step before it is saved. The stored image is exactly 512\u00d7512 and the previous file is deleted. The SSH keys tab lists fingerprint, label, and added time, and accepts OpenSSH .pub lines only. Email preferences lists only domains the user has signed in to. Each has an allow-email toggle (default allowed). Clicking a domain opens the subjects and times of the last 10 emails that site sent. Friends lists mutual friends and single-use invite links.",
"responses": [
{
"status": 200,
"content_type": "text/html",
"description": "Account page."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?error=Sign+in+to+manage+your+account.",
"description": "Signed out."
}
]
},
{
"method": "POST",
"path": "/account/avatar",
"auth": "web_cookie",
"content_type": "multipart/form-data",
"body": [
"csrf",
"avatar",
"avatar_cropped"
],
"description": "Replace the signed-in user profile picture. avatar_cropped must be 1; the crop step is required at signup and when changing the picture. The previous file is deleted immediately. The stored image is always a 512\u00d7512 square. Passkey-only and SSH sign-in can use this route.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/account?message=Profile+picture+updated.",
"description": "Picture replaced."
},
{
"status": 302,
"content_type": "redirect",
"location": "/account?error=Crop+the+picture+before+saving+it.",
"description": "File posted without the crop step."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?error=Sign+in+to+manage+your+account.",
"description": "Signed out."
}
]
},
{
"method": "POST",
"path": "/account/avatar/clear",
"auth": "web_cookie",
"content_type": "application/x-www-form-urlencoded",
"body": [
"csrf"
],
"description": "Remove the signed-in user profile picture and delete the file.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/account?message=Profile+picture+removed.",
"description": "Picture removed."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?error=Sign+in+to+manage+your+account.",
"description": "Signed out."
}
]
},
{
"method": "GET",
"path": "/avatar/{userId}",
"auth": "none",
"query": [
"v"
],
"description": "Profile picture bytes for a user id. The image is always exactly 512\u00d7512. No email or username is returned. Unknown users and users without a picture both 404. v is the avatar version from avatar_url; a matching v is cached for a long time. Replacing the picture deletes the old file and increments v.",
"responses": [
{
"status": 200,
"content_type": "image/webp",
"description": "Current 512\u00d7512 picture. Cache-Control is public, max-age=31536000, immutable when v matches."
},
{
"status": 404,
"content_type": "application/json",
"body": {
"error": "Not found."
},
"description": "No picture for that id."
}
]
},
{
"method": "POST",
"path": "/account/ssh-keys",
"auth": "web_cookie",
"content_type": "application/x-www-form-urlencoded",
"body": [
"csrf",
"label",
"public_key"
],
"description": "Register one OpenSSH public key for the signed-in user. label is 1-64 characters. public_key is a single .pub line. Private key material is rejected. The same fingerprint cannot be registered twice.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=ssh-keys&message=SSH+key+added.",
"description": "Key stored."
},
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=ssh-keys&error=Paste+an+OpenSSH+public+key+(.pub).+Private+keys+are+not+accepted.",
"description": "Private key or invalid format."
},
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=ssh-keys&error=Invalid+session.",
"description": "Invalid CSRF token."
}
]
},
{
"method": "POST",
"path": "/account/ssh-keys/delete",
"auth": "web_cookie",
"content_type": "application/x-www-form-urlencoded",
"body": [
"csrf",
"id"
],
"description": "Remove one SSH public key owned by the signed-in user.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=ssh-keys&message=SSH+key+removed.",
"description": "Key removed."
},
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=ssh-keys&error=That+SSH+key+was+not+found.",
"description": "Unknown id or another user's key."
}
]
},
{
"method": "POST",
"path": "/account/password/code",
"auth": "web_cookie",
"body": [
"csrf"
],
"description": "Email an 8-digit one-time code to the verified address. Valid for password_change_code_ttl_seconds.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=password&message=A+code+was+sent+to+your+email+address.",
"description": "Code sent."
},
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=password&error=Verify+your+email+before+using+an+email+code.",
"description": "Email is not verified."
}
]
},
{
"method": "POST",
"path": "/account/password",
"auth": "web_cookie",
"body": [
"csrf",
"current_password",
"password",
"password_confirm",
"factor=email",
"code"
],
"description": "Change password using the current password plus an emailed code. factor=ssh or factor=passkey is rejected here; those confirmations use their finish endpoints. Other sessions are revoked.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=password&message=Password+updated.+Other+sessions+were+signed+out.",
"description": "Password updated."
},
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=password&error=Current+password+is+incorrect.",
"description": "Current password rejected."
}
]
},
{
"method": "POST",
"path": "/account/password/passkey/begin",
"auth": "web_cookie",
"content_type": "application/json",
"body": [
"csrf"
],
"description": "Start a passkey assertion used as the second factor for a password change.",
"responses": [
{
"status": 200,
"content_type": "application/json",
"description": "WebAuthn publicKey request options."
}
]
},
{
"method": "POST",
"path": "/account/password/passkey/finish",
"auth": "web_cookie",
"content_type": "application/json",
"body": [
"csrf",
"current_password",
"password",
"password_confirm",
"id",
"rawId",
"response"
],
"description": "Finish the passkey assertion and change the password. Other sessions are revoked.",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"ok": true,
"redirect": "/account?tab=password&message=Password+updated.+Other+sessions+were+signed+out."
}
}
]
},
{
"method": "POST",
"path": "/account/email",
"auth": "web_cookie",
"body": [
"csrf",
"email",
"current_password"
],
"description": "Request an email change confirmed with the current password. Rejected when passkey-only login is enabled, and when factor is ssh or passkey. The address does not change until GET /confirm-email-change.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=email&message=Check+the+new+inbox...",
"description": "Confirmation mail sent to the new address."
},
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=email&error=Passkey-only+login+is+enabled.+Confirm+the+email+change+with+a+passkey+or+an+SSH+key.",
"description": "Password is not accepted for passkey-only accounts."
},
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=email&error=Email+is+already+registered.",
"description": "Address belongs to another account."
}
]
},
{
"method": "POST",
"path": "/account/email/passkey/begin",
"auth": "web_cookie",
"content_type": "application/json",
"body": [
"csrf"
],
"description": "Start a passkey assertion used to confirm an email change.",
"responses": [
{
"status": 200,
"content_type": "application/json",
"description": "WebAuthn publicKey request options."
}
]
},
{
"method": "POST",
"path": "/account/email/passkey/finish",
"auth": "web_cookie",
"content_type": "application/json",
"body": [
"csrf",
"email",
"id",
"rawId",
"response"
],
"description": "Finish the passkey assertion and send the new-address confirmation link.",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"ok": true,
"redirect": "/account?tab=email&message=Check+the+new+inbox..."
}
}
]
},
{
"method": "POST",
"path": "/account/ssh-confirm/challenge",
"auth": "web_cookie",
"content_type": "application/json",
"body": [
"csrf",
"purpose"
],
"description": "Create an SSHSIG challenge for a signed-in step-up. purpose is password_change, email_change, or passkey_only. The challenge is bound to that user and cannot be used for POST /api/ssh-login/verify. Requires a registered SSH key.",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"challenge_id": "64 hex chars",
"challenge": "64 hex chars",
"namespace": "timfalken-account",
"sign_command": "echo -n 'CHALLENGE' | ssh-keygen -Y sign -n timfalken-account -f ~/.ssh/id_ed25519",
"expires_in": 300
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "Register an SSH key before using this option."
}
}
]
},
{
"method": "POST",
"path": "/account/password/ssh/finish",
"auth": "web_cookie",
"content_type": "application/json",
"body": [
"csrf",
"current_password",
"password",
"password_confirm",
"challenge_id",
"public_key",
"signature"
],
"description": "Change the password after the current password checks out and an SSH signature satisfies the same step-up a passkey would. Other sessions are revoked.",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"ok": true,
"redirect": "/account?tab=password&message=Password+updated.+Other+sessions+were+signed+out."
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "SSH confirmation failed."
}
}
]
},
{
"method": "POST",
"path": "/account/email/ssh/finish",
"auth": "web_cookie",
"content_type": "application/json",
"body": [
"csrf",
"email",
"challenge_id",
"public_key",
"signature"
],
"description": "Request an email change after an SSH signature. Allowed for passkey-only accounts. The address does not change until GET /confirm-email-change.",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"ok": true,
"redirect": "/account?tab=email&message=Check+the+new+inbox..."
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "SSH confirmation failed."
}
}
]
},
{
"method": "POST",
"path": "/webauthn/passkey-only/ssh/finish",
"auth": "web_cookie",
"content_type": "application/json",
"body": [
"csrf",
"enabled",
"challenge_id",
"public_key",
"signature"
],
"description": "Turn passkey-only login on or off with an SSH signature. Enabling requires at least one passkey or one SSH key. A passkey assertion on POST /webauthn/passkey-only/finish still works and is not required when an SSH key is used.",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"ok": true,
"passkey_only": true
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "SSH confirmation failed."
}
}
]
},
{
"method": "GET",
"path": "/confirm-email-change",
"auth": "none",
"query": [
"token"
],
"description": "Confirm a pending email change. Marks the new address verified and leaves the previous address active until this succeeds.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/account?tab=email&message=Email+address+updated.",
"description": "Valid token while signed in."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?message=Email+address+updated.",
"description": "Valid token while signed out."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?error=Email+change+link+is+invalid+or+has+expired.",
"description": "Invalid or expired token."
}
]
},
{
"method": "GET",
"path": "/oauth/authorize",
"auth": "web_cookie (after login redirect)",
"query": [
"client_id",
"redirect_uri",
"response_type=code",
"state"
],
"description": "OAuth authorization; shows consent when authenticated.",
"example": "https://auth.timfalken.com/oauth/authorize?client_id=timfalken.com&redirect_uri=https%3A%2F%2Fblog.timfalken.com%2Fauth%2Fcallback&response_type=code&state=RANDOM",
"responses": [
{
"status": 200,
"content_type": "text/html",
"description": "Consent screen when user is signed in."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?return_to=%2Foauth%2Fauthorize%3F...",
"description": "Unauthenticated user sent to login first."
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "client_id, redirect_uri, and state are required."
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "Unknown client_id."
}
}
]
},
{
"method": "POST",
"path": "/oauth/authorize",
"auth": "web_cookie",
"body": [
"csrf",
"client_id",
"redirect_uri",
"state",
"approve",
"email_opt_in_presented?",
"allow_site_email?"
],
"description": "Approves sign-in. On the first approval for this user and domain, email_opt_in_presented=1 is sent and allow_site_email=1 stores that the site may email the user (omit allow_site_email to opt out). Later approvals do not change the stored preference.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "https://blog.timfalken.com/auth/callback?code=AUTHORIZATION_CODE&state=CLIENT_STATE",
"description": "User approved; browser redirect with one-time code."
},
{
"status": 200,
"content_type": "application/json",
"body": {
"redirect": "https://blog.timfalken.com/auth/callback?code=AUTHORIZATION_CODE&state=CLIENT_STATE"
},
"description": "User approved; JSON redirect URL when Accept includes application/json."
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "Invalid CSRF token."
}
},
{
"status": 401,
"content_type": "application/json",
"body": {
"error": "Authentication required."
}
}
]
},
{
"method": "POST",
"path": "/oauth/token",
"auth": "client_secret",
"content_type": "application/x-www-form-urlencoded",
"headers": {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"User-Agent": "Client identifier (recommended: YourSiteAccountClient/1.0)"
},
"body": [
"grant_type=authorization_code",
"code",
"client_id",
"client_secret",
"redirect_uri"
],
"example_request": "POST https://auth.timfalken.com/oauth/token\nContent-Type: application/x-www-form-urlencoded\nAccept: application/json\nUser-Agent: MySiteAccountClient/1.0\n\ngrant_type=authorization_code&code=...&redirect_uri=...&client_id=...&client_secret=...",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"access_token": "a1b2c3d4e5f6789012345678abcdef01",
"token_type": "Bearer",
"expires_in": 86400,
"user": {
"id": 1,
"username": "tim",
"avatar_url": "https://auth.timfalken.com/avatar/1?v=1"
}
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "Invalid client credentials."
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "Invalid or expired authorization code."
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "redirect_uri does not match the authorized value."
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "Unsupported grant_type."
}
}
]
},
{
"method": "GET",
"path": "/api/me",
"auth": "bearer",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"user": {
"id": 1,
"username": "tim",
"is_admin": false,
"email_verified": true,
"passkey_only": false,
"avatar_url": "https://auth.timfalken.com/avatar/1?v=1"
}
}
},
{
"status": 401,
"content_type": "application/json",
"body": {
"error": "Bearer token required."
}
},
{
"status": 401,
"content_type": "application/json",
"body": {
"error": "Invalid or expired token."
}
}
]
},
{
"method": "POST",
"path": "/api/session/extend",
"auth": "bearer",
"description": "Sliding session renewal; resets TTL to session_ttl_seconds.",
"example_request": "POST https://auth.timfalken.com/api/session/extend\nAuthorization: Bearer ACCESS_TOKEN\nAccept: application/json\nUser-Agent: MySiteAccountClient/1.0",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"expires_at": "2026-06-05 19:00:00",
"expires_in": 86400
}
},
{
"status": 401,
"content_type": "application/json",
"body": {
"error": "Bearer token required."
}
},
{
"status": 401,
"content_type": "application/json",
"body": {
"error": "Invalid or expired token."
}
}
]
},
{
"method": "POST",
"path": "/api/logout",
"auth": "bearer",
"description": "Revokes the bearer token server-side.",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"ok": true
}
},
{
"status": 401,
"content_type": "application/json",
"body": {
"error": "Bearer token required."
}
},
{
"status": 401,
"content_type": "application/json",
"body": {
"error": "Invalid or expired token."
}
}
]
},
{
"method": "GET",
"path": "/api/friends",
"auth": "bearer",
"description": "Friends of the user identified by the bearer token. Each friend is a stable id, username, and avatar_url. Email addresses are not included. avatar_url is null when that friend has no picture.",
"example_request": "GET https://auth.timfalken.com/api/friends\nAuthorization: Bearer ACCESS_TOKEN\nAccept: application/json\nUser-Agent: MySiteAccountClient/1.0",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"friends": [
{
"id": 2,
"username": "ada",
"avatar_url": "https://auth.timfalken.com/avatar/2?v=3"
}
]
}
},
{
"status": 401,
"content_type": "application/json",
"body": {
"error": "Bearer token required."
}
},
{
"status": 401,
"content_type": "application/json",
"body": {
"error": "Invalid or expired token."
}
}
]
},
{
"method": "POST",
"path": "/api/email",
"auth": "oauth_client",
"content_type": "application/json",
"description": "Send HTML email to a user on behalf of the authenticated client domain. Authenticate with client_id and client_secret in the body (the same client credentials as POST /oauth/token). The user bearer token is not accepted. The address is never returned. A footer is always appended: \"Sent by {client_id} via {auth host}\" plus a link to /account?tab=email-preferences. HTML is sanitized (scripts, iframes, and javascript URLs removed). Subject max length and HTML byte size are in constants. Rate limits: email_relay_per_user_per_hour successful-or-attempted messages per user per domain, and email_relay_per_client_per_hour per domain.",
"body": [
"client_id",
"client_secret",
"username",
"subject",
"html"
],
"example_request": "POST https://auth.timfalken.com/api/email\nContent-Type: application/json\nAccept: application/json\nUser-Agent: MySiteAccountClient/1.0\n\n{\n \"client_id\": \"timfalken.com\",\n \"client_secret\": \"YOUR_SECRET\",\n \"username\": \"ada\",\n \"subject\": \"Your turn\",\n \"html\": \"<p>Hello <strong>Ada</strong>.</p>\"\n}",
"responses": [
{
"status": 200,
"content_type": "application/json",
"body": {
"ok": true
},
"description": "Email accepted and sent."
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "username, subject, and html are required.",
"code": "invalid_input"
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "Subject is too long.",
"code": "invalid_input"
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "HTML body is too large.",
"code": "invalid_input"
}
},
{
"status": 400,
"content_type": "application/json",
"body": {
"error": "HTML body is empty after sanitization.",
"code": "invalid_input"
}
},
{
"status": 401,
"content_type": "application/json",
"body": {
"error": "Invalid client credentials.",
"code": "invalid_client"
}
},
{
"status": 403,
"content_type": "application/json",
"body": {
"error": "This user has never signed in to this site.",
"code": "never_signed_in"
}
},
{
"status": 403,
"content_type": "application/json",
"body": {
"error": "This user has turned off email from this site.",
"code": "email_opted_out"
}
},
{
"status": 404,
"content_type": "application/json",
"body": {
"error": "Unknown user.",
"code": "unknown_user"
}
},
{
"status": 429,
"content_type": "application/json",
"body": {
"error": "Too many emails to this user from this site. Try again later.",
"code": "rate_limited"
}
},
{
"status": 502,
"content_type": "application/json",
"body": {
"error": "The email could not be sent.",
"code": "delivery_failed"
}
}
]
},
{
"method": "GET",
"path": "/friends/invite/{token}",
"auth": "web_cookie",
"description": "Shows who sent a friend invite. Signed-out visitors are redirected to login and returned here. Accept and decline are POST /friends/invite/{token}/accept and /decline with csrf.",
"responses": [
{
"status": 200,
"content_type": "text/html",
"description": "Invite page (open, self, already friends, expired, used, or revoked)."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?return_to=%2Ffriends%2Finvite%2FTOKEN",
"description": "Not signed in."
}
]
},
{
"method": "GET",
"path": "/admin",
"auth": "admin_web_cookie",
"query": [
"tab=domains|smtp|users"
],
"description": "Admin UI for OAuth domains, SMTP settings, and users.",
"responses": [
{
"status": 200,
"content_type": "text/html",
"description": "Admin panel."
},
{
"status": 302,
"content_type": "redirect",
"location": "/login?error=Admin+access+required.",
"description": "Non-admin or guest."
}
]
},
{
"method": "POST",
"path": "/admin/domains",
"auth": "admin_web_cookie",
"body": [
"csrf",
"domain",
"paths (newline-separated)"
],
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/admin?tab=domains&message=Domain+registered.",
"description": "Domain added; new client_secret flashed once in session."
}
]
},
{
"method": "POST",
"path": "/admin/smtp",
"auth": "admin_web_cookie",
"description": "Save SMTP settings; blank password keeps existing password.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/admin?tab=smtp&message=SMTP+settings+saved.",
"description": "Settings saved."
}
]
},
{
"method": "POST",
"path": "/admin/smtp/test",
"auth": "admin_web_cookie",
"description": "Send test email using form values.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/admin?tab=smtp&message=Test+email+sent+to+admin%40example.com.",
"description": "Test email sent."
}
]
},
{
"method": "POST",
"path": "/admin/users/block",
"auth": "admin_web_cookie",
"body": [
"csrf",
"user_id"
],
"description": "Block sign-in and revoke sessions, bearer tokens, and unused authorization codes. Admins cannot block themselves or the last admin.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/admin?tab=users&id=2&message=User+blocked.+They+can+no+longer+sign+in.",
"description": "User blocked."
}
]
},
{
"method": "POST",
"path": "/admin/users/unblock",
"auth": "admin_web_cookie",
"body": [
"csrf",
"user_id"
],
"description": "Allow a blocked user to sign in again. Previous sessions stay revoked.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/admin?tab=users&id=2&message=User+unblocked.+They+can+sign+in+again.",
"description": "User unblocked."
}
]
},
{
"method": "POST",
"path": "/admin/users/reset-password",
"auth": "admin_web_cookie",
"body": [
"csrf",
"user_id"
],
"description": "Email a 10-character random temporary password and revoke that user's sessions. Passkey-only login is turned off so the temporary password can be used.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/admin?tab=users&id=2&message=Temporary+password+emailed.+Their+sessions+were+signed+out.",
"description": "Temporary password emailed."
}
]
},
{
"method": "POST",
"path": "/admin/users/delete",
"auth": "admin_web_cookie",
"body": [
"csrf",
"user_id",
"confirm=delete"
],
"description": "Permanently delete the user and dependent passkeys, sessions, email tokens, and OAuth grants. Requires confirm=delete. Admins cannot delete themselves or the last admin.",
"responses": [
{
"status": 302,
"content_type": "redirect",
"location": "/admin?tab=users&message=User+deleted.",
"description": "User deleted."
}
]
}
],
"user_object": {
"note": "The email address is never included in token responses, GET /api/me, GET /api/friends, or any other connected-site payload. To reach the user by email, call POST /api/email with the site client credentials.",
"api_me": {
"id": "integer user id",
"username": "string",
"is_admin": "boolean; first registered user is admin",
"email_verified": "boolean",
"passkey_only": "boolean",
"avatar_url": "HTTPS URL of the 512\u00d7512 profile picture, or null when the user has none. The v query changes when the picture is replaced."
},
"oauth_token": {
"id": "integer user id",
"username": "string",
"avatar_url": "HTTPS URL of the 512\u00d7512 profile picture, or null"
},
"friend": {
"id": "integer user id",
"username": "string",
"avatar_url": "HTTPS URL of the 512\u00d7512 profile picture, or null"
}
},
"integration_checklist": [
"Deploy over HTTPS and set account_url in config.local.php if needed.",
"Admin registers client domain and callback paths at /admin.",
"Store client_secret server-side only.",
"Redirect users to GET /oauth/authorize with state for CSRF protection.",
"In the callback handler, POST /oauth/token with Content-Type application/x-www-form-urlencoded.",
"Send User-Agent and Accept application/json on every server-to-server account request.",
"Persist access_token server-side (session) and use Authorization Bearer on /api/* calls.",
"Call GET /api/me to validate tokens; POST /api/session/extend before expiry. Do not expect an email field. avatar_url is an HTTPS URL or null.",
"Call GET /api/friends with the user bearer token to list friends (id, username, and avatar_url).",
"Call POST /api/email with client_id and client_secret (not the user token) to email a user who has signed in to your domain and allowed mail.",
"Call POST /api/logout to revoke bearer tokens on sign-out.",
"Agents can register an SSH public key and call POST /api/ssh-login/challenge plus POST /api/ssh-login/verify, including when passkey-only login is on, then open the returned redeem_path or send the session_token as the account_session cookie before continuing at /oauth/authorize.",
"Add a user-settings control that immediately exports all of that user's data as JSON (modal or .json file download)."
]
}