Version: 1.0.0
Category: Authentication
Status: Stable
The Authentication Protocol defines how AI agents should authenticate and authorize when interacting with Structs APIs. This includes session or bearer authentication for the web application, transaction signing for the consensus network, and NATS authentication for streaming.
Key Principles:
Use Case: Authenticating with structs-webapp API
Implementation: PHP Symfony application (structs-webapp)
Method: Wallet-signature login producing a session cookie or an opaque bearer token
Base URL: http://localhost:8080
Base Path: /api/auth
Note: The API built into structs-webapp is the main user-facing API. Authentication is implemented in the PHP Symfony application.
Use Case: Submitting transactions to structsd
Method: Transaction signing using private keys
Base URL: http://localhost:1317
Transaction Endpoint: /cosmos/tx/v1beta1/txs
Use Case: Connecting to GRASS streaming service
Method: Optional NATS authentication
NATS Protocol: nats://localhost:4222
NATS WebSocket: ws://localhost:1443
The webapp validates a signed login message from an approved address in the
requested guild. POST /api/auth/login creates a session; POST /api/auth/token
accepts the same proof and returns an opaque bearer token with expires_in: 86400.
The token is not a JWT. Send it with Authorization: Bearer <token>; an invalid
Bearer credential does not fall back to a session cookie.
The authentication reference lists the exact anonymous guild profile/directory/roster/stat routes. Signup, login, native registration proof/status, timestamp, settings and onboarding events are public. Other entity/catalog reads and private device data require authentication. Public guild GET/HEAD supports anonymous cross-origin reads; private bearer access still requires an appropriate deployment CORS policy in a browser.
Wallet proofs are hex-encoded compressed secp256k1 public keys (66 characters) and raw R followed by S signatures (128 characters), signing SHA-256 of the UTF-8 message. This encoding is separate from the base64 protobuf fields used in native transactions.
Webapp authentication and device scope reviewed against integration 5991ca95.
Other sections retain their independent source baselines; the chain version alone
does not certify a guild API deployment.
Step 0: Build and sign the message (SignatureValidationManager::buildLoginMessage):
LOGIN_GUILD{guildId}ADDRESS{address}DATETIME{unix_timestamp}
unix_timestamp (unix seconds) must be a canonical decimal string less than 600 seconds from server time in either direction.
Step 1: Login Request
{
"request": {
"method": "POST",
"url": "http://localhost:8080/api/auth/login",
"headers": {
"Content-Type": "application/json"
},
"body": {
"address": "structs1...",
"signature": "hex-raw-signature",
"pubkey": "hex-compressed-pubkey",
"guild_id": "0-1",
"unix_timestamp": "1715000000"
}
}
}
Step 2: Login Response
{
"response": {
"status": 200,
"headers": {
"Set-Cookie": "PHPSESSID=abc123def456; Path=/; HttpOnly"
},
"body": {
"success": true,
"errors": [],
"data": null
}
}
}
On failure the server returns 401 with keyed errors (signature_validation_failed, player_address_does_not_exists, player_does_not_exists).
Step 3: Using Session Cookie (browser clients use credentials: include)
{
"request": {
"method": "GET",
"url": "http://localhost:8080/api/reactor/all/page/1",
"headers": {
"Cookie": "PHPSESSID=abc123def456",
"Accept": "application/json"
}
}
}
{
"flow": "webapp-login",
"steps": [
{
"step": 1,
"action": "login",
"request": {
"method": "POST",
"url": "/api/auth/login",
"body": {
"address": "structs1...",
"signature": "hex-raw-signature",
"pubkey": "hex-compressed-pubkey",
"guild_id": "0-1",
"unix_timestamp": "1715000000"
}
},
"response": {
"status": 200,
"cookie": "PHPSESSID=abc123def456"
},
"store": "session.cookie"
},
{
"step": 2,
"action": "authenticated-request",
"request": {
"method": "GET",
"url": "/api/reactor/all/page/1",
"headers": {
"Cookie": ""
}
}
}
]
}
Registration is also signature-based and asynchronous: persisting the pending player triggers a GuildMembershipJoinProxy chain message, and the player ID is assigned later. Login must be called separately once the ID exists. Signed message (buildGuildMembershipJoinProxyMessage): GUILD{guildId}ADDRESS{address}NONCE{nonce}.
Request:
{
"request": {
"method": "POST",
"url": "http://localhost:8080/api/auth/signup",
"headers": {
"Content-Type": "application/json"
},
"body": {
"primary_address": "structs1...",
"signature": "hex-raw-signature",
"pubkey": "hex-compressed-pubkey",
"guild_id": "0-1",
"username": "new_player",
"pfp": "optional-pfp"
}
}
}
Response:
{
"response": {
"status": 202,
"body": {
"success": true,
"errors": [],
"data": null
}
}
}
Request:
{
"request": {
"method": "POST",
"url": "http://localhost:8080/api/auth/logout",
"headers": {
"Cookie": "PHPSESSID=abc123def456"
}
}
}
Response:
{
"response": {
"status": 200,
"body": {
"success": true,
"errors": [],
"data": null
}
}
}
Logout invalidates the presented server session and revokes the presented bearer until its expiry; other tokens are unaffected. Clear local credentials even when HTTP logout is unavailable. Retain the public journal for any uncertain native transaction. Repeating logout is safe. An API logout alone does not revoke a key on chain.
Session Storage:
{
"session": {
"cookie": "PHPSESSID=abc123def456",
"expires": "2025-12-07 12:00:00",
"lastUsed": "2025-12-07 11:00:00"
}
}
Session Validation:
Overview: Transactions must be signed with a private key before submission.
Process:
{
"body": {
"messages": [
{
"@type": "/structs.structs.MsgStructBuildInitiate",
"creator": "structs1abc...",
"playerId": "1-11",
"structTypeId": "1",
"operatingAmbit": "land",
"slot": "0"
}
],
"memo": "",
"timeout_height": "0",
"extension_options": [],
"non_critical_extension_options": []
},
"auth_info": {
"signer_infos": [
{
"public_key": {
"@type": "/cosmos.crypto.secp256k1.PubKey",
"key": "base64_public_key"
},
"mode_info": {
"single": {
"mode": "SIGN_MODE_DIRECT"
}
},
"sequence": "0"
}
],
"fee": {
"amount": [],
"gas_limit": "200000",
"payer": "",
"granter": ""
}
},
"signatures": [
"base64_signature"
]
}
Step 1: Get Account Information
{
"request": {
"method": "GET",
"url": "http://localhost:1317/cosmos/auth/v1beta1/accounts/structs1abc..."
},
"response": {
"account": {
"address": "structs1abc...",
"pub_key": {...},
"account_number": "0",
"sequence": "0"
}
}
}
Step 2: Create Transaction
{
"transaction": {
"body": {
"messages": [...],
"memo": ""
},
"auth_info": {
"signer_infos": [...],
"fee": {
"amount": [],
"gas_limit": "200000"
}
}
}
}
Step 3: Sign Transaction
{
"signing": {
"method": "secp256k1",
"privateKey": "0x...",
"message": "transaction_bytes",
"result": "base64_signature"
}
}
Step 4: Submit Transaction
{
"request": {
"method": "POST",
"url": "http://localhost:1317/cosmos/tx/v1beta1/txs",
"headers": {
"Content-Type": "application/json"
},
"body": {
"tx_bytes": "base64_encoded_signed_transaction",
"mode": "BROADCAST_MODE_SYNC"
}
}
}
TypeScript (generated bindings + CosmJS):
Generate the message types from the structsd repo with make proto-gen-ts, register them with CosmJS, and sign with a SigningStargateClient:
import { OfflineSigner, Registry } from '@cosmjs/proto-signing';
import { SigningStargateClient } from '@cosmjs/stargate';
// Generated by `make proto-gen-ts` in the structsd repo:
import { MsgStructBuildInitiate } from './generated/structs/structs/tx';
const signer: OfflineSigner = /* get signer from Keplr or other wallet */;
const registry = new Registry();
registry.register('/structs.structs.MsgStructBuildInitiate', MsgStructBuildInitiate);
const client = await SigningStargateClient.connectWithSigner(
'http://localhost:26657',
signer,
{ registry }
);
const [account] = await signer.getAccounts();
const msg = {
typeUrl: '/structs.structs.MsgStructBuildInitiate',
value: {
creator: account.address,
playerId: '1-11',
structTypeId: 1,
operatingAmbit: 2, // ambit enum: water 1, land 2, air 3, space 4
slot: 0
}
};
const result = await client.signAndBroadcast(account.address, [msg], {
amount: [],
gas: '200000'
});
Python:
from cosmpy.aerial.client import LedgerClient
from cosmpy.aerial.wallet import LocalWallet
from cosmpy.crypto.keypairs import PrivateKey
# Initialize wallet
private_key = PrivateKey.from_hex("...")
wallet = LocalWallet(private_key)
# Initialize client
client = LedgerClient("http://localhost:1317")
# Create and sign transaction
tx = client.execute_tx(
msgs=[msg],
sender=wallet,
gas_limit=200000
)
NATS Protocol:
{
"connection": {
"url": "nats://localhost:4222",
"protocol": "NATS",
"authentication": "optional"
}
}
NATS WebSocket:
{
"connection": {
"url": "ws://localhost:1443",
"protocol": "NATS",
"transport": "WebSocket",
"authentication": "optional"
}
}
Token Authentication:
{
"connection": {
"url": "nats://localhost:4222",
"authentication": {
"type": "token",
"token": "nats_token_here"
}
}
}
Username/Password Authentication:
{
"connection": {
"url": "nats://localhost:4222",
"authentication": {
"type": "username_password",
"username": "nats_user",
"password": "nats_password"
}
}
}
JavaScript/TypeScript:
import { connect } from 'nats';
// Connect to NATS
const nc = await connect({
servers: ['nats://localhost:4222'],
// Optional authentication
// token: 'nats_token',
// user: 'nats_user',
// pass: 'nats_password'
});
// Subscribe to subjects
const sub = nc.subscribe('structs.player.1', (msg) => {
console.log('Received:', msg.data);
});
// Keep connection alive
await nc.closed();
Python:
import asyncio
from nats.aio.client import Client as NATS
async def main():
nc = NATS()
await nc.connect('nats://localhost:4222')
# Optional authentication
# await nc.connect('nats://localhost:4222', token='nats_token')
async def message_handler(msg):
print(f'Subject: {msg.subject}')
print(f'Data: {msg.data.decode()}')
await nc.subscribe('structs.player.1', cb=message_handler)
# Keep connection alive
await asyncio.sleep(3600)
await nc.close()
asyncio.run(main())
401 Unauthorized (Webapp):
{
"response": {
"status": 401,
"body": {
"success": false,
"errors": {"authentication_error": "Login required"},
"data": null
}
},
"action": "re-authenticate",
"retry": true
}
401 Unauthorized (Consensus):
{
"response": {
"status": 401,
"body": {
"code": 4,
"message": "unauthorized"
}
},
"action": "check-signer",
"retry": false
}
403 Forbidden:
{
"response": {
"status": 403,
"body": {
"error": "Forbidden",
"message": "Insufficient permissions"
}
},
"action": "check-permissions",
"retry": false
}
Session Expired:
{
"error": "session-expired",
"recovery": {
"step1": "detect-401-error",
"step2": "clear-session",
"step3": "re-authenticate",
"step4": "retry-original-request"
}
}
Signature Validation Failed:
{
"error": "signature_validation_failed",
"recovery": {
"step1": "log-error",
"step2": "rebuild-login-message-with-fresh-unix-timestamp",
"step3": "re-sign-and-retry",
"note": "signatures expire after 600s; confirm the address is an approved guild member"
}
}
Transaction Signing Failed:
{
"error": "signing-failed",
"recovery": {
"step1": "check-private-key",
"step2": "verify-account-sequence",
"step3": "if-send-was-possible-reconcile-the-original-hash-before-any-new-signature"
}
}
{
"webapp": {
"baseUrl": "http://localhost:8080",
"address": "${WEBAPP_ADDRESS}",
"pubkey": "${WEBAPP_PUBKEY}",
"privateKey": "${WEBAPP_PRIVATE_KEY}",
"guildId": "${WEBAPP_GUILD_ID}"
},
"consensus": {
"rpcURL": "http://localhost:26657",
"apiURL": "http://localhost:1317",
"privateKey": "${PRIVATE_KEY}",
"address": "${ADDRESS}"
},
"streaming": {
"natsURL": "nats://localhost:4222",
"natsWebSocketURL": "ws://localhost:1443",
"token": "${NATS_TOKEN}"
}
}
Testnet:
{
"webapp": {
"baseUrl": "https://testnet.structs.network"
},
"consensus": {
"rpcURL": "https://public.testnet.structs.network:26657",
"apiURL": "https://public.testnet.structs.network"
},
"streaming": {
"natsURL": "nats://testnet.structs.network:4222",
"natsWebSocketURL": "wss://testnet.structs.network:1443"
}
}
Mainnet:
{
"webapp": {
"baseUrl": "https://structs.network"
},
"consensus": {
"rpcURL": "https://rpc.structs.network:26657",
"apiURL": "https://api.structs.network:1317"
},
"streaming": {
"natsURL": "nats://structs.network:4222",
"natsWebSocketURL": "wss://structs.network:1443"
}
}
{
"example": "complete-authentication-flow",
"steps": [
{
"step": 1,
"service": "webapp",
"action": "login",
"request": "POST /api/auth/login",
"store": "session.cookie"
},
{
"step": 2,
"service": "webapp",
"action": "authenticated-request",
"request": "GET /api/guild/this",
"headers": {
"Cookie": ""
}
},
{
"step": 3,
"service": "consensus",
"action": "sign-transaction",
"message": "MsgStructBuildInitiate",
"signer": "private_key"
},
{
"step": 4,
"service": "consensus",
"action": "submit-transaction",
"request": "POST /cosmos/tx/v1beta1/txs"
},
{
"step": 5,
"service": "streaming",
"action": "connect-nats",
"url": "nats://localhost:4222"
},
{
"step": 6,
"service": "streaming",
"action": "subscribe",
"subject": "structs.player.1"
}
]
}
Last Updated: December 7, 2025
Logout method rechecked against webapp 461bba5a (AuthController and GuildAPI): POST only. Other sections retain their existing verification scope.