Authentication
OPAQUE password-authenticated key exchange — registration and login flows.
Literal uses the OPAQUE protocol for authentication—a password-authenticated key exchange that ensures your password never leaves your device. The flow has two phases: registration and login, each with a start and finish step.
All auth endpoints are rate-limited. Repeated failures will result in 429 Too Many Requests.
Note: OPAQUE requires a client-side library to handle the cryptographic handshake. We recommend @serenity-kit/opaque for JavaScript/TypeScript environments, which is battle-tested and actively maintained. The Literal SDK (coming soon) will wrap this handshake automatically — for now, your client must orchestrate the two-step registration and login flows.
Register an Account
Registration is a two-step OPAQUE handshake. Your client will use an OPAQUE library (e.g., @serenity-kit/opaque) to orchestrate the exchange.
Prerequisite — Deriving login_bidx: Both registration and login require a login_bidx
value derived via an OPRF challenge. If you have not done this yet, see
Deriving The Login Bucket first.
Step 1 — Start registration
Send your client-derived login blind index and the blinded OPAQUE registration request from the client library:
curl -X POST https://api.kyndex.co/v1/auth/opaque/register-start \
-H 'Content-Type: application/json' \
-d '{
"login_bidx": 42,
"registration_request": "<base64-opaque-blinded-request>"
}'Request body:
| Field | Type | Description |
|---|---|---|
login_bidx | integer | OPRF-derived login bucket (integer 0-8191) |
registration_request | string | OPAQUE blinded registration request (base64), produced by opaque.client.startRegistration() |
Response (200 OK):
{
"registration_response": "<base64-opaque-server-message>"
}| Field | Description |
|---|---|
registration_response | Server's OPAQUE registration response (base64), passed to opaque.client.finishRegistration() |
Step 2 — Finish registration
Pass the server's response through your OPAQUE client library. It will produce a registration_record (the authentication verifier) and you will derive your UMK (User Master Key) using Argon2id. Encrypt your private keys with the UMK, then send everything to the server:
curl -X POST https://api.kyndex.co/v1/auth/opaque/register-finish \
-H 'Content-Type: application/json' \
-d '{
"id": "550e8400-e29b-41d4-a716-446655440000",
"login_bidx": 42,
"registration_record": "<base64-opaque-registration-verifier>",
"encryption_salt": "<base64-32-bytes>",
"mlkem_public_key": "<base64-1568-bytes>",
"x25519_public_key": "<base64-32-bytes>",
"mlkem_private_encrypted": "<base64-aes-256-gcm-ciphertext>",
"signing_public_key": "<base64-1984-bytes>",
"signing_private_encrypted": "<base64-aes-256-gcm-ciphertext>",
"recovery_key_encrypted": "<base64-optional-recovery-key>",
"umk_backup": "<base64-optional-backed-up-umk>"
}'Request body:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Client-generated user UUID |
login_bidx | integer | Yes | OPRF-derived login bucket (integer 0-8191, must match step 1) |
registration_record | string | Yes | OPAQUE registration record from opaque.client.finishRegistration() (base64) |
encryption_salt | string | Yes | 32-byte random salt for UMK derivation (base64) |
mlkem_public_key | string | Yes | ML-KEM-1024 public key, 1568 bytes (base64) |
x25519_public_key | string | Yes | X25519 public key, 32 bytes (base64) |
mlkem_private_encrypted | string | Yes | ML-KEM private key encrypted with UMK (base64) |
signing_public_key | string | Yes | Hybrid ML-DSA-65 + Ed25519 signing public key, 1984 bytes (base64) |
signing_private_encrypted | string | Yes | Signing private key encrypted with UMK (base64) |
recovery_key_encrypted | string | No | Optional recovery key, encrypted with UMK (base64). Required only if umk_backup is also provided. |
umk_backup | string | No | Optional UMK encrypted with recovery key (base64). Required only if recovery_key_encrypted is also provided. |
Response (201 Created):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-01-15T10:30:00.000Z"
}| Field | Description |
|---|---|
id | Unique user ID (UUID) |
created_at | Account creation timestamp (ISO 8601) |
Zero-Knowledge Note — Neither your password nor your email appear in any request. The server
receives only a client-derived blind index (login_bidx) and OPAQUE protocol messages.
The OPAQUE protocol ensures the server stores only a verifier (registration_record) — it
cannot derive your password from it. Your private keys and email are encrypted with your UMK
(User Master Key, derived client-side via Argon2id) before transmission. The server stores only
the ciphertext; decryption happens on your device.
Log In
Login is also a two-step OPAQUE handshake. Your client uses the OPAQUE library to orchestrate the exchange.
Step 1 — Start login
Send your client-derived login blind index and the blinded OPAQUE login request:
curl -X POST https://api.kyndex.co/v1/auth/opaque/authenticate-start \
-H 'Content-Type: application/json' \
-d '{
"login_bidx": 42,
"login_request": "<base64-opaque-blinded-login-request>"
}'Request body:
| Field | Type | Description |
|---|---|---|
login_bidx | integer | OPRF-derived login bucket (integer 0-8191) |
login_request | string | OPAQUE blinded login request (base64), produced by opaque.client.startLogin() |
Response (200 OK):
{
"login_responses": [
"<base64-opaque-candidate-0>",
"<base64-opaque-candidate-1>",
"..."
],
"login_session_id": "c8a6b7f8-d9e0-4516-a2b3-c4d5e6f7a8b9"
}| Field | Description |
|---|---|
login_responses | Padded array of OPAQUE login response candidates (base64). Contains real and dummy entries, shuffled. See note below. |
login_session_id | Short-lived session ID (UUID), valid for 5 minutes. Required for step 2. |
Multi-candidate login: The server pads the login_responses array with dummy entries to prevent bucket-occupancy oracle attacks. Your client must try all candidates with opaque.client.finishLogin() — dummy entries will fail, the real entry will succeed. Do not short-circuit on the first success; try all candidates before submitting, otherwise you leak the successful position via response timing. Record the index of the candidate that succeeded as candidate_index for step 2.
Step 2 — Finish login
Pass the server's response through your OPAQUE client library. It will produce a login_finish message. Simultaneously, derive three session tokens on your client:
owner_token— used to identify your personal documentsuser_member_token— used to identify your entity membershipsrevocation_token— used for identity-free logout across all sessions
Send these to complete the login:
curl -X POST https://api.kyndex.co/v1/auth/opaque/authenticate-finish \
-H 'Content-Type: application/json' \
-d '{
"login_session_id": "c8a6b7f8-d9e0-4516-a2b3-c4d5e6f7a8b9",
"candidate_index": 0,
"login_finish": "<base64-opaque-finish-message>",
"owner_token": "<base64-32-bytes>",
"user_member_token": "<base64-32-bytes>",
"revocation_token": "<base64-32-bytes>"
}'Request body:
| Field | Type | Description |
|---|---|---|
login_session_id | string | Session ID from step 1 (UUID) |
candidate_index | integer | Index of the candidate from login_responses that succeeded in opaque.client.finishLogin() |
login_finish | string | OPAQUE finish message from opaque.client.finishLogin() (base64) |
owner_token | string | 32-byte token identifying your personal scope (base64) |
user_member_token | string | 32-byte token identifying your entity memberships (base64) |
revocation_token | string | 32-byte token for identity-free session revocation (base64) |
Response (200 OK):
{
"access_token": "YWNjZXNzdG9rZW4zMmJ5dGVzYmFzZTY0ZW5jb2RlZA==",
"refresh_token": "cmVmcmVzaHRva2VuMzJieXRlc2Jhc2U2NA==",
"access_expires_at": "2026-01-15T10:45:00.000Z",
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email_encrypted": "<base64-aes-256-gcm-ciphertext-or-null>",
"key_version": 1,
"mlkem_private_encrypted": "<base64-encrypted-ml-kem-private-key>",
"signing_private_encrypted": "<base64-encrypted-signing-private-key>",
"recovery_key_encrypted": "<base64-encrypted-recovery-key>"
},
"entity_memberships": [
{
"id": "a6e4f5d6-b7c8-4394-e0f1-a2b3c4d5e6f7",
"entity_id": "e4c2d3b4-f5a6-4172-c8d9-e0f1a2b3c4d5",
"role": "member",
"euk_epoch": 1
}
]
}Response fields:
| Field | Description |
|---|---|
access_token | Short-lived bearer token (32 bytes, base64). Include in Authorization: Bearer <token> header. TTL: 15 minutes. |
refresh_token | Long-lived refresh token (32 bytes, base64). Used to obtain a new access token when expired. TTL: 7 days, single-use. |
access_expires_at | ISO 8601 timestamp when access token expires. |
user.id | Your user ID (UUID). |
user.email_encrypted | Your email encrypted with UMK (base64, nullable). Decrypt client-side for display. |
user.key_version | Current cryptographic key version. Use as x_key_version when decrypting keys with canAad. |
user.mlkem_private_encrypted | Your ML-KEM private key, encrypted with your UMK. Decrypt locally using your derived UMK. |
user.signing_private_encrypted | Your signing private key, encrypted with your UMK. Decrypt locally using your derived UMK. |
user.recovery_key_encrypted | Your recovery key (if set), encrypted with UMK. Omitted if you did not set a recovery key during registration. |
entity_memberships | Array of your active entity memberships. Empty if you have no entity memberships. |
What To Store After Login
After a successful login, store on your client:
access_token— Use in every authenticated request:Authorization: Bearer <access_token>refresh_token— Keep secure; use to obtain a new access token before expiry- Decrypted private keys — Decrypt
mlkem_private_encryptedandsigning_private_encryptedusing your UMK, then store in memory - Session tokens (
owner_token,user_member_token,revocation_token) — Store securely for subsequent API operations and logout
Use the access_token in subsequent authenticated requests:
Authorization: Bearer <access_token>Token Lifecycle & Refresh
| Token | TTL | Notes |
|---|---|---|
| Access token | 15 minutes | Short-lived; include in every authenticated request |
| Refresh token | 7 days | Single-use; rotated on each refresh |
When your access token expires, exchange the refresh token for a new pair:
curl -X POST https://api.kyndex.co/v1/auth/tokens/refresh \
-H 'Content-Type: application/json' \
-d '{
"refresh_token": "<your-refresh-token>",
"owner_token": "<base64-32-bytes>",
"user_member_token": "<base64-32-bytes>"
}'Response:
{
"access_token": "<new-access-token>",
"refresh_token": "<new-refresh-token>",
"access_expires_at": "2026-01-15T11:00:00.000Z"
}owner_token and user_member_token are optional. Omitting them produces a locked session — authenticated but unable to access document operations. Include them to maintain an unlocked session.
Each refresh token is single-use. After refreshing, discard the old token and store the new one.
POST /auth/logout invalidates the current session. POST /auth/logout-all revokes every active
session for the account.
Quickstart
Register, authenticate, upload an encrypted document, and search it — end to end.
API Conventions
Schemas and endpoint signatures live in the API Reference. This page covers behavior — how authentication works, how errors are shaped, what headers to expect, and the guarantees the server makes.