Authentication and JWTs¶
Kuru Relay issues a short-lived JWT after a secondary trading wallet proves control of its address. Whether the wallet needs prior approval depends on the deployment's wallet-admission mode.
The JWT authenticates the caller to the relay. It does not grant AccountCore TRADE permission, install EIP-7702 delegation, replace the wallet's EIP-712 signatures, or revoke on-chain permission when the token expires. Those checks remain independent.
Who can get a token?¶
Any secondary EOA admitted by the deployment that can sign the relay's challenge can request its own token. The relay verifies the signature and current admission policy, then mints a JWT bound to that wallet.
Testnet deployments have two mutually exclusive admission modes:
| Mode | Who can authenticate? |
|---|---|
| Static allowlist | Only addresses listed by the operator. Admission is managed out of band. |
| Allow-all | Every valid nonzero EOA. No prior address registration or allowlist update is required. |
In allow-all mode, a new EOA effectively self-registers for Relay API access by completing the challenge flow. The registry does not create a durable user account; it evaluates the address as admitted whenever the relay checks it.
The initial testnet flow supports canonical EOA signatures only. ERC-1271 smart-contract wallet signatures are not accepted. The JWT signing secret remains inside the relay and must never be sent to a client.
Login flow¶
admitted secondary EOA
│
│ 1. request challenge
▼
POST /auth/challenge
│
│ 2. sign the exact returned message with personal_sign
▼
POST /auth/token
│
│ 3. relay verifies signature and admission policy
▼
wallet-bound bearer JWT
│
├─ POST /relay
└─ WebSocket upgrade to /relay/ws
Login proves control of an admitted wallet. It does not require the wallet to be delegated yet, so a newly created trading EOA can authenticate before its onboarding transaction.
1. Request a challenge¶
curl --fail-with-body \
--request POST \
--url https://api.relay.testnet.kuru.io/auth/challenge \
--header 'Content-Type: application/json' \
--data '{"wallet":"0x1111111111111111111111111111111111111111"}'
The only request field is wallet, a nonzero 20-byte Ethereum address. A successful response is:
{
"challengeId": "8c4a11c6eb574d16972fac2a8c85a6dc",
"message": "api.relay.testnet.kuru.io wants you to sign in with your Ethereum account:\n0x1111111111111111111111111111111111111111\n\nAuthenticate this wallet to Kuru Relay.\n\nURI: https://api.relay.testnet.kuru.io/auth/token\nVersion: 1\nChain ID: 10143\nNonce: 6cd8d853db2a45edaf655a60fb808b87\nIssued At: 2026-08-27T10:00:00Z\nExpiration Time: 2026-08-27T10:05:00Z\nRequest ID: 8c4a11c6eb574d16972fac2a8c85a6dc",
"expiresAt": "2026-08-27T10:05:00Z"
}
Sign the exact UTF-8 message returned by the relay using Ethereum personal_sign (ERC-191). Do not reconstruct, normalize, or reformat it. The message binds the wallet, testnet chain ID, domain, token URI, issue time, expiry, nonce, and challenge ID.
For example, a viem wallet client can sign it with:
const signature = await walletClient.signMessage({
account: tradingWallet,
message: challenge.message,
})
Use the wallet provider's personal-message signing API if it exposes a different interface.
2. Exchange the signature¶
curl --fail-with-body \
--request POST \
--url https://api.relay.testnet.kuru.io/auth/token \
--header 'Content-Type: application/json' \
--data '{
"challengeId":"8c4a11c6eb574d16972fac2a8c85a6dc",
"signature":"0x<65-byte-personal-sign-signature>"
}'
The relay recovers the signer, requires it to equal the challenged wallet, checks that the wallet is still admitted under the deployment's current mode, and consumes the challenge. A challenge can succeed only once, including when exchanges race concurrently.
A successful response is:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsImtpZCI6InRlc3RuZXQtcHJpbWFyeSIsInR5cCI6IkpXVCJ9...",
"tokenType": "Bearer",
"expiresAt": "2026-08-27T11:00:00Z",
"wallet": "0x1111111111111111111111111111111111111111"
}
Testnet challenges expire after five minutes and tokens after one hour. Request a new challenge and repeat the flow when the token expires. Treat accessToken as a bearer secret: keep it in short-lived session storage and never log it.
3. Use the token¶
For REST, send the JWT in every relay request:
curl --fail-with-body \
--request POST \
--url https://api.relay.testnet.kuru.io/relay \
--header "Authorization: Bearer $KURU_RELAY_JWT" \
--header 'Content-Type: application/json' \
--data @request.json
The request wallet must match the JWT wallet. For WebSocket, send the same header during the HTTP upgrade; the token binds the entire connection to that wallet.
Failure behavior¶
Authentication failures deliberately reveal little detail:
{
"code": "AUTHENTICATION_FAILED",
"message": "authentication failed",
"retryable": false
}
| Status | Meaning | Client action |
|---|---|---|
400 |
The JSON, address, challenge ID, or signature format is malformed. | Correct the request. |
401 |
The challenge or signature is invalid, expired, consumed, mismatched, or the wallet is not admitted. | Request a fresh challenge; in static mode, confirm the wallet is allowlisted. |
403 |
The browser origin is not admitted by the deployment. | Use an approved integration origin. |
405 |
The route was called with a method other than POST. |
Use POST. |
413 |
The request body is too large. | Send only the documented fields. |
415 |
The request is not application/json. |
Set the correct content type. |
429 |
An IP, wallet, concurrency, or capacity limit was reached. | Honor Retry-After when present and back off. |
500 |
The relay could not safely classify the client boundary. | Do not retry immediately; report the failure. |
503 |
Authentication or its allowlist dependency is temporarily unavailable. | Back off and request a new challenge before retrying. |
Authentication responses use Cache-Control: no-store and must not be cached. Do not retry the same token exchange after an ambiguous network failure: request a new challenge instead.
On-chain authorization is separate¶
After authentication, the main wallet still grants the secondary EOA TRADE permission and the secondary EOA installs its EIP-7702 delegation during onboarding. JWT expiry stops relay API access but does not revoke those on-chain rights. The AccountCore owner must revoke the secondary wallet separately when the trading session should end.
Local operator token¶
A self-hosted testnet operator can still create a token directly from the relay repository:
make testnet-jwt WALLET=0x1111111111111111111111111111111111111111 TTL=1h
This command requires the deployment's KURU_RELAY_JWT_SECRET and is intended for local operations, not client integrations. Never distribute that secret.