Skip to content

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.