Authentication
toda has one identity: a Solana wallet. Every user is their public key. There are no passwords, no email addresses, and no account recovery - because there is no account, only a keypair and the log its public key derives.
Two credential types reach the API:
| Credential | Prefix | Issued to | Lifetime |
|---|---|---|---|
| Session JWT | - | Browsers, after a wallet signature | 1 hour |
| API key | toda_ | Headless agents, by an authenticated user | Until revoked |
Both arrive as Authorization: Bearer <token> and resolve to the same userId and ownerPubkey.
Sign-In With Solana
SIWS is toda's login. It follows the EIP-4361 structured-message grammar that the Solana Wallet Standard adopted: the server issues a message, the wallet signs it with ed25519, and the server verifies the signature against the claimed public key.
1. Request a challenge
POST /auth/challenge
{ "publicKey": "7xKX…" }
The server generates a single-use nonce (128 bits, base58), builds the canonical message, persists it, and returns it:
toda.io wants you to sign in with your Solana account:
7xKX…
Sign in to Toda - verifiable memory for AI agents.
URI: https://app.toda.io
Version: 1
Chain ID: devnet
Nonce: 4bTxK9…
Issued At: 2026-07-08T12:00:00.000Z
Expiration Time: 2026-07-08T12:05:00.000Z
Field order and labels are fixed. They are the signed bytes. buildSiwsMessage() and parseSiwsMessage() are pure and dependency-free so the wallet, the browser, and the verifier all agree on what was signed.
2. Sign and verify
POST /auth/verify
{ "publicKey": "7xKX…", "message": "<the exact message>", "signature": "<base58 ed25519>" }
The server performs five checks, in this order:
- Parse the message structurally and confirm
message.address === publicKey. A malformed message is rejected before any state is touched. - Atomically consume the nonce - a
DELETE … RETURNINGagainstsiws_nonces, scoped to(nonce, publicKey). A replayed signature finds no row. - Check expiry against the persisted
expiresAt. The challenge TTL is 5 minutes. - Compare the message byte-for-byte against the one that was issued for that nonce. A signature over a different message with a valid nonce is rejected.
- Verify the ed25519 signature with
nacl.sign.detached.verify.
Only then is the user upserted (enrolling a wrapped data key on first sight) and a session JWT issued.
Why nonces live in the database
They survive restarts, they work across horizontally scaled API instances, and - the point - they are strictly single-use. Verification deletes the row atomically as it reads it. Two concurrent verifications of the same signature race, and exactly one wins.
An in-memory nonce set gets all three of these wrong.
SIWS_DOMAIN, SIWS_URI, and SIWS_CHAIN_ID are part of the signed bytes. A signature harvested by a phishing site for evil.example does not verify against a challenge issued for your domain, because the server compares the full message text against the one it stored. Set these to your real deployment values.
Session tokens
A successful verify returns an HS256 JWT:
{ "sub": "<userId>", "pk": "<ownerPubkey>", "iat": …, "exp": … }
- Signed under
JWT_SECRET(minimum 32 characters, validated at startup). - 1 hour TTL. There is no refresh endpoint; re-sign to renew.
- Stateless - verification does not touch the database, so revocation before expiry is not possible on the main API.
JWT_SECRET is a plaintext-equivalent secretAnyone holding it can mint a token for any userId and read that user's decrypted memories through the API. It sits alongside MASTER_ENCRYPTION_KEY in the key inventory.
The extension API uses stateful session tokens instead: a random token whose SHA-256 is stored in extension_sessions, supporting POST /auth/logout and server-side revocation. The trade is a database read per request.
API keys
Headless agents cannot sign a wallet message on every call. An authenticated user mints a long-lived key instead:
POST /keys
{ "label": "trading-agent-prod" }
{
"id": "…",
"key": "toda_9f2a4c7e1b6d8a03f5e2c1b9d7a4e6f8c3b1d9e7a5f2c4b6",
"prefix": "toda_9f2a4c7",
"label": "trading-agent-prod",
"createdAt": "2026-07-08T12:00:00.000Z"
}
Structure and handling:
- The raw key is
toda_followed by 48 hex characters - 24 bytes of CSPRNG output. - Only its SHA-256 is stored. The database cannot produce the key.
- The raw value is returned exactly once, at creation. There is no endpoint that reveals it again.
prefix- the first 12 characters - is stored in the clear so the UI can identify a key without holding it.
GET /keys list keys (prefix, label, lastUsedAt, revokedAt)
DELETE /keys/:id revoke
Revocation is immediate: the middleware looks the key hash up on every request.
Resolving a bearer token
if token starts with "toda_":
hash it, look up api_keys, resolve userId
else:
verify the JWT signature, read sub
then: load the user, set userId + ownerPubkey + wrapped data key
The wrapped data key is loaded once, in the middleware, so route handlers never re-query for it.
Authorization
There is exactly one authorization rule, and it is enforced by query scope:
Every read and write is scoped to the authenticated
userIdin the SQL itself.
A memory belonging to another user is not loaded and then rejected - it is never loaded. getMemory(db, userId, id) takes the userId as a parameter, not as an afterthought. getProof on someone else's memory id returns 404, not 403, because from the caller's perspective it does not exist.
There are no roles, no ACLs, and no sharing primitive on the main API. Sharing is expressed as a capability grant instead: a bearer URL that carries its own expiry and use count, and that grants exactly one operation.
What authentication does not protect
Authentication gates reads of plaintext. It does not gate integrity, and it never could - a proof is verifiable by anyone, which is the entire point. If someone holds a memory and its proof, they can check it against the chain whether or not they can authenticate to toda.
That asymmetry is the design: reading is permissioned, verifying is permissionless.