Skip to main content

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:

CredentialPrefixIssued toLifetime
Session JWT-Browsers, after a wallet signature1 hour
API keytoda_Headless agents, by an authenticated userUntil 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:

  1. Parse the message structurally and confirm message.address === publicKey. A malformed message is rejected before any state is touched.
  2. Atomically consume the nonce - a DELETE … RETURNING against siws_nonces, scoped to (nonce, publicKey). A replayed signature finds no row.
  3. Check expiry against the persisted expiresAt. The challenge TTL is 5 minutes.
  4. 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.
  5. 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.

The message binds the domain, not just the nonce

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 secret

Anyone 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 userId in 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.