Scope note. This document currently covers HTTP request authentication for the indexer's REST API. The Soroban contract function reference, storage layout, and event schema are not here yet — see
packages/contractsand the README API table in the meantime.
- Stellar HTTP Authentication (v1)
- The Signed Message
- Canonical Path
- Body Hash
- The Authorization Header
- Verification Order and Error Codes
- Worked Example
- Protected Endpoints
- Known Limitations of v1
Protected endpoints authenticate the caller by an Ed25519 signature over a message derived from the request itself. The signature commits to the HTTP method, the request path, and a hash of the request body, so a credential captured on one endpoint cannot be replayed against another.
The canonical implementation lives in packages/types/src/auth.ts (buildAuthMessage,
canonicalizeAuthPath) and is shared by the verifier and the first-party clients. External
integrators should implement the format below directly — see the
migration guide for a self-contained reference implementation.
The message begins with a version tag so the scheme can be rotated without a flag day. A
future v2: can add fields (a nonce, header binding, the query string) while a server
accepts both prefixes during a transition window, routing each credential to the verifier
that matches its tag. Without the prefix, any change to the layout would require every
client and every server to cut over in the same instant.
The prefix is part of the signed bytes, so it cannot be swapped by an attacker to downgrade a v2 credential into a v1 one.
v1:{METHOD}:{canonicalPath}:{address}:{timestamp}:{bodyHash}
| Field | Description |
|---|---|
v1 |
Literal version tag. |
METHOD |
HTTP method, upper-cased: GET, POST, PATCH, DELETE. |
canonicalPath |
Request path after canonicalisation — see §3. |
address |
Signer's Stellar public key (G…), exactly as sent in the header payload. |
timestamp |
Unix epoch in milliseconds, as an integer with no separators. |
bodyHash |
Lowercase hex SHA-256 of the raw body — see §4. |
Fields are joined with a literal :. No field is escaped or length-prefixed. A path can
legitimately contain a : (/api/items:batchGet is a valid URL), so the message is not
unambiguous by construction — it is unambiguous because the last three fields have fixed
shapes (a 56-character base32 address, digits, 64 hex characters), which lets the suffix be
read unambiguously from the right no matter what the path contains.
What gets signed is the SHA-256 digest of this message, not the message itself:
signature = Ed25519_sign(privateKey, SHA256(message))
The digest is signed as 32 raw bytes. The signature is the raw 64-byte Ed25519 output, base64-encoded for transport.
Wallet signers do not do this today. The rule above is what the server verifies, and it is what a signer holding the key directly (
Keypair.sign) produces. Browser wallets are a different matter: Freighter'ssignBlobtreats its argument as an opaque message, wraps it in the SEP-0053 envelope and signsSHA256("Stellar Signed Message:\n" + blob)— never the 32 raw bytes. It also returns aBuffer, not the base64 string its TypeScript signature promises. Both facts are verified against a real Freighter 2.0.0 signature. Consequently the first-party browser client does not currently interoperate with this server, and reconciling the two is an open decision, not something this document describes as settled. If you sign with a wallet, expect the envelope; if you sign with a library, follow the rule above.
The server canonicalises req.originalUrl, which always carries the full path including
any router mount prefix. Clients must sign the same absolute path.
The rule, in order:
- Drop everything from the first
?onward. - Strip all trailing
/. - If the result is empty, use
/.
| Raw path | Canonical path |
|---|---|
/api/follows |
/api/follows |
/api/follows/ |
/api/follows |
/api/follows// |
/api/follows |
/api/follows?cursor=5 |
/api/follows |
/api/follows/?cursor=5 |
/api/follows |
/ |
/ |
// |
/ |
/api//follows |
/api//follows |
Two consequences worth internalising:
- Sign
/api/notifications/preferences, never the router-relative/preferences. Signing the relative path produces a different message and the request comes back401. - If your base URL carries a path segment of its own (
https://example.com/indexer), that segment reaches the server as part ofreq.originalUrlbut never appears in the path you passed to the signer. Keep the base URL an origin only, or include the prefix in the signed path.
bodyHash is the lowercase hex SHA-256 of the exact bytes sent as the request body.
When there is no body, hash the empty string:
SHA256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
This is the value used for every GET, and for any request sent without a body. The server
reaches the same value either way: a request with no JSON content type never triggers
body-parser's verify hook and its absent raw body is hashed as empty, while a JSON request
with an empty body triggers the hook with a zero-length buffer, which hashes to the same
digest.
Hash the bytes you send, not the object you serialised. Serialise once into a variable, then use that same variable for both the hash and the request body. Serialising separately for each can yield different bytes — key order, whitespace, number formatting — and the signature would then commit to a body that was never sent, producing a
401that is very hard to read.
Authorization: StellarSig <base64(JSON)>
The base64 payload decodes to a JSON object with exactly these three fields:
{
"address": "G...",
"timestamp": 1735689600000,
"signature": "<base64 of the raw 64-byte Ed25519 signature>"
}The header value is the literal StellarSig, one space, then the base64 payload. The parser
splits on a single space and rejects anything else, so the payload must not contain spaces.
address and timestamp are sent in the clear and covered by the signature — the
server uses the transmitted values to rebuild the message, then checks the signature against
it. Tampering with either produces a message that no longer verifies.
The server checks in this order and returns on the first failure:
| Order | Check | Status | Code |
|---|---|---|---|
| 1 | Header present, parsable, all fields | 400 |
INVALID_AUTH_HEADER |
| 2 | timestamp is not in the future |
403 |
INVALID_TIMESTAMP |
| 3 | timestamp is at most 30 s old |
403 |
EXPIRED_TIMESTAMP |
| 4 | Signature matches the rebuilt message | 401 |
INVALID_SIGNATURE |
Errors carry the shape:
{
"error": {
"code": "INVALID_SIGNATURE",
"message": "Invalid signature",
"requestId": "..."
}
}The order matters when diagnosing a replay. Timestamp checks run before the signature
check, so an expired credential returns 403 regardless of which path it is presented at —
you will not see the 401 that a wrong path would otherwise produce. A credential replayed
against a different path within the tolerance window is what yields 401.
The tolerance is 30 000 ms, defined by SIGNATURE_TIMESTAMP_TOLERANCE_MS in
services/indexer/src/middleware/stellarAuth.ts. Clock skew between client and server eats
directly into this budget; a client running more than 30 s fast is rejected outright as
INVALID_TIMESTAMP.
Every value below is reproducible — the keypair is seeded with 32 bytes of 0x07.
address GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57
method POST
path /api/follows
timestamp 1735689600000
body {"followee":"GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"}
Step 1 — hash the body:
bodyHash = 420fe3b7b804bb866cbfeb113d913ba1543099424aed7694297cc3f042ee29a9
Step 2 — build the message:
v1:POST:/api/follows:GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57:1735689600000:420fe3b7b804bb866cbfeb113d913ba1543099424aed7694297cc3f042ee29a9
Step 3 — digest it (base64, for inspection):
1bWsu8SkJG10Q5vEoWQZdo3ZIbseNrILzJmuOsP2VyM=
Step 4 — sign the digest and base64 the signature:
evOK1CFjMZSTvHtuXrxhDVImMQi9JByqzurli1avrRrEPyjvhhQHYwyAmJLvicSAgHEnOte0qXFBlafhOHjtAw==
Step 5 — assemble the header:
Authorization: StellarSig eyJhZGRyZXNzIjoiR0RWRVUzREQ0S09GRUNWNjZWSUhXRVpPWVg0WktSM1dWMjdMNDY0U0lJUE9VMklVSTNKQ1pBNTciLCJ0aW1lc3RhbXAiOjE3MzU2ODk2MDAwMDAsInNpZ25hdHVyZSI6ImV2T0sxQ0ZqTVpTVHZIdHVYcnhoRFZJbU1RaTlKQnlxenVybGkxYXZyUnJFUHlqdmhoUUhZd3lBbUpMdmljU0FnSEVuT3RlMHFYRkJsYWZoT0hqdEF3PT0ifQ==
Note that this example's timestamp is long past, so replaying it against a live server
returns 403 EXPIRED_TIMESTAMP. It is for verifying your message construction, not for
testing a live endpoint.
| Method | Path | Body |
|---|---|---|
POST |
/api/notifications/register |
JSON |
POST |
/api/notifications/deregister |
JSON |
GET |
/api/notifications/preferences |
none |
POST |
/api/notifications/preferences |
JSON |
POST |
/api/messages |
JSON |
All other indexer endpoints are public reads and take no Authorization header.
The signature covers the method, the canonical path, and the body. Everything else about the request — headers, query string, transport — is outside it. Three consequences deserve to be stated plainly.
Canonicalisation strips everything after ? before signing, so query parameters are not
authenticated. A credential captured for /api/posts?limit=10 is equally valid for
/api/posts?limit=1000 within the tolerance window.
No currently protected endpoint reads query parameters, so there is nothing to tamper with
today. That is a property of the current route set, not a guarantee of the scheme — do not
add a query parameter to a protected endpoint without moving it into the body or extending
the scheme to v2.
The scheme binds a credential to a request shape, not to one delivery of it. Anyone who observes a request can resend it byte-for-byte — same method, same path, same body — and it will be accepted until the 30-second window closes. For a non-idempotent endpoint that means the action happens twice.
The window is the only bound. Defending against identical replay needs a nonce carried in the signed message plus server-side storage of spent nonces for at least the tolerance period, which v1 does not have. Until then, TLS is what stands between an observer and a replayable credential: these endpoints must not be served over plaintext HTTP.
The server captures the raw body through express.json({ verify }), and body-parser only
invokes that hook when the request's Content-Type is JSON. For any other content type the
raw body is never captured, bodyHash falls back to the hash of the empty string, and the
body travels entirely outside the signature — a multipart/form-data upload on a
protected route would be freely tamperable while the request still verifies.
Every protected endpoint is JSON-only today, so this is latent rather than live. It becomes a
live vulnerability the moment a protected route accepts another content type. Any new
protected endpoint must be JSON, or the raw-body capture in
services/indexer/src/middleware/rawBody.ts must be widened to cover its content type first.