⚠️ BASE_URL is not set. Open the page source and set the BASE_URL constant to your deployed Bastion instance. Playground calls will fail until then.

Live API Documentation

Bastion API

End-to-end encrypted anonymous messaging backend built with Java Spring Boot. No passwords. No email. Identity is a cryptographic key pair.

Every API on this page is live. Your demo account was auto-created when you loaded this page.

Your Demo Session Initializing…
Private key is stored in localStorage and never transmitted to the server. Key pair generated with Web Crypto API (Ed25519).

Architecture

Bastion uses passwordless Ed25519 key-pair authentication: the client generates a key pair locally, sends only the public key at registration, and proves identity by signing a server-issued nonce. No password is ever stored or transmitted.

JWTs are stateless (24h expiry) but invalidation is enforced via two paths: explicit token revocation on logout (stored in revoked_tokens), and timestamp-based rejection when a user rotates their key pair (keyRotatedAt check).

Messages are encrypted client-side before leaving the device. The server stores only ciphertext and a decryption nonce and has no ability to read message content. Real-time delivery uses a WebSocket/STOMP pipeline; messages are also persisted for history retrieval.

Account recovery uses 8 BCrypt-hashed one-time codes generated at registration, allowing key replacement without any server-stored password.

Client Device Bastion API (Spring Boot) PostgreSQL DB ── REGISTRATION generateKeyPair() Ed25519, stays on device { username, publicKey } POST /api/auth/register INSERT user validate, store publicKey INSERT recovery_keys BCrypt-hash 8 recovery OTPs { userId, recoveryKeys[8] } ── LOGIN (challenge-response) { userId } POST /api/auth/challenge INSERT challenge generate UUID nonce, 120s TTL { nonce, expiresAt } sign(nonce, privateKey) Ed25519, local only { userId, signature } POST /api/auth/verify SELECT challenge verify Ed25519 signature mark as used { token: JWT (24h) } ── MESSAGING encrypt(plaintext) AES-256-GCM, local only { recipientId, cipherText, nonce } POST /api/message/send INSERT message store ciphertext opaquely push to WebSocket topic /topic/messages/{recipientId} { id, deliveryStatus: "DELIVERED" }

API Reference

Authentication — Public (no token required)
POST /api/auth/register Register with username + public key
Registration requires only a username and an Ed25519 public key in SPKI/X.509 format, no email, no phone, no password. The server stores the public key and returns a userId plus eight one-time recovery codes (shown once, never again). These codes are your only fallback if you lose your private key.
Request Body
FieldTypeRequiredDescription
usernamestringyes2–50 characters, must be unique
publicKeystringyesBase64-encoded Ed25519 public key in SPKI (X.509 SubjectPublicKeyInfo) format
Response
{
  "userId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "username": "demo_abc123",
  "recoveryKeys": [
    "a3f9e2c1b4d5e6f7890a1b2c",
    "d1e2f3a4b5c6d7e8f9a0b1c2",
    "e5f6a7b8c9d0e1f2a3b4c5d6",
    "f1a2b3c4d5e6f7a8b9c0d1e2",
    "a9b0c1d2e3f4a5b6c7d8e9f0",
    "b3c4d5e6f7a8b9c0d1e2f3a4",
    "c7d8e9f0a1b2c3d4e5f6a7b8",
    "d2e3f4a5b6c7d8e9f0a1b2c3"
  ]
}
{
  "Error: ": "Username already taken"
}
Try it
This will register a new account with a freshly generated key pair. Your existing demo session is not affected.
Request body
Generates a fresh Ed25519 key pair, then registers
Response headers

              
POST /api/auth/challenge Request a login challenge nonce
Step 1 of the challenge-response login flow. The server generates a UUID nonce, stores it with a 120-second expiry, and returns it. Any previously unused challenge for this user is automatically invalidated. The nonce must be signed by the client's private key and submitted to /verify before expiry.
Request Body
FieldTypeRequiredDescription
userIdUUIDyesThe user's ID returned from registration
Response
{
  "nonce": "550e8400-e29b-41d4-a716-446655440000",
  "expiresAt": "2026-05-14T10:02:00"
}
{
  "Error: ": "User not found"
}
Try it
Request body
Response headers

              
POST /api/auth/verify Submit signed nonce, receive JWT
Step 2 of login. The client signs the nonce (UTF-8 bytes of the UUID string) with their Ed25519 private key using crypto.subtle.sign("Ed25519", privateKey, nonceBytes) and submits the base64-encoded signature. The server verifies using the stored public key (via X509EncodedKeySpec + Java's Signature.getInstance("Ed25519")). On success, a 24-hour JWT is issued. No password ever crosses the wire.
Request Body
FieldTypeRequiredDescription
userIdUUIDyesThe user's ID
signaturestringyesBase64-encoded Ed25519 signature of the UTF-8 nonce bytes
Response
{
  "token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJmNDdhYzEwYi01OGNjLTQzNzItYTU2Ny0wZTAyYjJjM2Q0NzkiLCJ1c2VybmFtZSI6ImRlbW9fYWJjMTIzIiwiaWF0IjoxNzE1Njc4ODAwLCJleHAiOjE3MTU3NjUyMDB9.example_sig"
}
{
  "Error: ": "Invalid signature"
}
Try it
This playground runs the full challenge → sign → verify flow automatically using your demo session's private key. The nonce is signed with Ed25519 in the browser; the raw private key is never displayed.
Response headers

              
POST /api/auth/recover Recover account with a one-time code
Allows a user who has lost their private key to install a new key pair using one of their 8 BCrypt-hashed recovery codes. The provided plain-text code is compared against each stored hash via BCryptPasswordEncoder.matches(). On success, the matched code is marked used, the new public key replaces the old one, keyRotatedAt is updated (invalidating all prior JWTs), and a fresh JWT is issued.
Request Body
FieldTypeRequiredDescription
usernamestringyesThe account's username
recoveryKeystringyesOne of the 8 plain-text recovery codes (32 hex chars, no dashes)
newPublicKeystringyesBase64 SPKI Ed25519 public key for the new key pair
Response
{
  "token": "eyJhbGciOiJIUzI1NiJ9..."
}
{
  "Error: ": "Invalid recovery key"
}
Try it
⚠ CautionUsing a real recovery key from your demo session will consume it and install a new public key, ending your current demo session. The body below uses illustrative placeholder values.
Request body
Response headers

              
Session Management — Authenticated (Bearer token required)
POST /api/auth/logout 🔒 auth Revoke current JWT
Adds the caller's JWT to the revoked_tokens table. Every subsequent request carrying that token is rejected by the JWT filter, even if the token has not yet expired naturally. This is path 1 of Bastion's dual-path JWT invalidation. The token's own expiry timestamp is extracted and stored alongside it so a scheduled job can prune expired rows.
Request Headers
HeaderValueRequired
AuthorizationBearer <JWT>yes
Response
"Logged out successfully"
{
  "Error: ": "Missing or invalid Authorization header"
}
Try it
⚠ CautionThis will revoke your demo session's JWT. You will need to click "Reset Demo Session" to get a new one.

No request body. Sends your demo session JWT as the Authorization header.

Authorization: Bearer [your demo JWT]
Response headers

              
PUT /api/auth/rotate-key 🔒 auth Replace public key, invalidate all prior sessions
Path 2 of dual-path JWT invalidation. Replaces the user's public key and sets keyRotatedAt to the current timestamp. The JWT filter checks every token's issuedAt claim against this timestamp: any token issued before the rotation is rejected outright, without needing individual revocation entries. This invalidates all existing sessions across all devices simultaneously.
Request Body
FieldTypeRequiredDescription
newPublicKeystringyesBase64 SPKI Ed25519 public key for the new key pair
Response
"Key rotated successfully. All previous sessions are now invalid."
{
  "Error: ": "JWT token is expired"
}
Try it
⚠ CautionThis installs a new public key and invalidates your current demo JWT. The newPublicKey field is pre-filled with your live session key (rotating to the same key is valid). Click "Reset Demo Session" after sending to get a new JWT.
Request body
Authorization: Bearer [your demo JWT]
Response headers

              
POST /api/auth/refresh-recovery-keys 🔒 auth Invalidate old recovery codes, generate 8 new ones
Marks all existing recovery keys as invalidated and generates 8 fresh BCrypt-hashed one-time codes. This is useful after using a recovery code (reducing the available count) or if you suspect a code has been compromised. The new codes are returned once and never shown again, please store them somewhere safe immediately.
Request

No request body. Authenticated via the Authorization: Bearer <token> header.

Response
{
  "recoveryKeys": [
    "a3f9e2c1b4d5e6f7890a1b2c",
    "d1e2f3a4b5c6d7e8f9a0b1c2",
    "e5f6a7b8c9d0e1f2a3b4c5d6",
    "f1a2b3c4d5e6f7a8b9c0d1e2",
    "a9b0c1d2e3f4a5b6c7d8e9f0",
    "b3c4d5e6f7a8b9c0d1e2f3a4",
    "c7d8e9f0a1b2c3d4e5f6a7b8",
    "d2e3f4a5b6c7d8e9f0a1b2c3"
  ],
  "message": "Recovery keys refreshed. Store these safely. They will not be shown again."
}
{
  "Error: ": "JWT token is expired"
}
Try it
⚠ CautionThis permanently destroys your current 8 demo recovery codes and replaces them with a new set. The new codes appear in the response — copy them immediately.

No request body. Sends your demo session JWT as the Authorization header.

Authorization: Bearer [your demo JWT]
Response headers

              

Messaging

Messaging — Authenticated (Bearer token required)
POST /api/message/send 🔒 auth Send an encrypted message
Accepts a pre-encrypted message payload. The server does not perform any encryption or decryption. It stores the cipherText and nonce opaquely and pushes them to the recipient's WebSocket topic (/topic/messages/{recipientId}). The delivery status starts as PENDING and transitions to DELIVERED on WebSocket delivery, then READ when the recipient fetches the conversation.
Request Body
FieldTypeRequiredDescription
recipientIdUUIDyesThe recipient's userId
cipherTextstringyesBase64-encoded encrypted message bytes. The server stores this as-is.
noncestringyesDecryption nonce/IV (base64). Required by the recipient to decrypt.
Response
{
  "id": "c4e8f2a1-3b5d-4c7e-9f0a-1b2c3d4e5f6a",
  "senderId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "recipientId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "cipherText": "9vKlM3pRqBnXzT7wY2aE5sD8hF0cJ4iG6uN1oL",
  "nonce": "dGhpcyBpcyBhIG5vbmNl",
  "deliveryStatus": "DELIVERED",
  "createdAt": "2026-05-14T10:00:00"
}
{
  "Error: ": "User not found"
}
Try it
The demo below sends a message from your demo account to itself (recipientId = your own userId). The cipherText is a real AES-GCM encrypted payload generated in your browser. The server stores it without reading it.
Request body
Encrypts "Hello from Bastion docs!" with AES-GCM, then sends
Response headers

              
GET /api/message/conversation/{contactId} 🔒 auth Fetch conversation history
Returns all messages exchanged between the authenticated user and contactId, in stored order. As a side effect, all messages in this conversation are marked as READ. The server returns raw ciphertext, decryption happens on the client using the recipient's private key.
Path Parameters
ParameterTypeDescription
contactIdUUIDThe other user's userId
Response
[
  {
    "id": "c4e8f2a1-3b5d-4c7e-9f0a-1b2c3d4e5f6a",
    "senderId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "recipientId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "cipherText": "9vKlM3pRqBnXzT7wY2aE5sD8hF0cJ4iG6uN1oL",
    "nonce": "dGhpcyBpcyBhIG5vbmNl",
    "deliveryStatus": "READ",
    "createdAt": "2026-05-14T10:00:00"
  }
]
{
  "Error: ": "JWT token is expired"
}
Try it

Fetches your self-conversation (messages sent to yourself). Send a message first using the playground above.

GET /api/message/conversation/{your userId}
Response headers

              
WebSocket / STOMP — Real-time Delivery

Bastion uses STOMP over WebSocket (with SockJS fallback) for real-time message delivery. Connect once with your JWT, subscribe to your personal topic, and incoming messages appear instantly, no polling.

The broker endpoint is /ws. When someone calls POST /api/message/send, the server stores the message and pushes it to the recipient's topic /topic/messages/{userId}. Authentication is passed as an Authorization native header in the STOMP CONNECT frame.

PropertyValue
SockJS endpoint{BASE_URL}/ws
Subscribe topic/topic/messages/{userId}
AuthCONNECT native header: Authorization: Bearer <token>
Triggered byPOST /api/message/send — server pushes to recipient's topic on delivery

Live STOMP Demo

Connect using your demo session JWT, then send a message to yourself via REST. Because you are subscribed to your own topic, it will arrive here in the frame log in real time.

Status DISCONNECTED Click Connect to start
Connects with Authorization: Bearer [demo JWT]
STOMP Frame Log
--:--:--Waiting for connection…
Send a message to yourself
Encrypts your text with AES-GCM and POSTs it to /api/message/send addressed to your own userId. Since you are subscribed to your topic, the push will appear above within milliseconds.
Client Integration (JavaScript)
import SockJS from 'sockjs-client';
import { Client } from '@stomp/stompjs';

const stompClient = new Client({
  webSocketFactory: () => new SockJS('https://api.basistth.dev/ws'),
  connectHeaders: {
    Authorization: `Bearer ${jwt}`
  },
  onConnect: () => {
    stompClient.subscribe(`/topic/messages/${userId}`, (frame) => {
      const msg = JSON.parse(frame.body);
      // msg contains: id, senderId, recipientId, cipherText, nonce, deliveryStatus
      // Decrypt with recipient's private key to recover plaintext
      console.log('Received:', msg);
    });
  },
  onStompError: (frame) => console.error('STOMP error:', frame)
});

stompClient.activate();

Users

Users — Authenticated (Bearer token required)

Encryption Deep-Dive

What the server stores

  • cipherText — opaque base64 blob
  • nonce — decryption IV (base64)
  • deliveryStatus — PENDING / DELIVERED / READ
  • User's publicKey — for signature verification only
  • BCrypt hashes of recovery codes

What the server never sees

  • Message plaintext — encrypted before leaving the device
  • Private keys — generated locally, never transmitted
  • Passwords — none exist in Bastion
  • Encryption keys — derived client-side and discarded
  • Recovery codes — only BCrypt hashes are stored

How client-side encryption works: Before sending a message, the sender's device derives a shared symmetric key using an ECDH-style key exchange between the sender's private key and the recipient's public key (e.g., X25519). This shared secret is used with a randomly generated nonce to encrypt the plaintext via AES-256-GCM (or a similar AEAD cipher). Only the resulting cipherText and nonce are sent to the server.

The recipient decrypts using their private key to re-derive the same shared secret. Because Ed25519 (signing) and X25519 (key exchange) use related but distinct key types, production implementations typically maintain separate key pairs per purpose. Bastion's current public key field covers the signing use case, with encryption key exchange as a planned extension.

Threat model: Even with full database access, an attacker sees only ciphertext. The private keys never leave the client devices. A compromised server reveals no credentials, no plaintext, and no usable key material.

Live Encryption Demo

Type a message below and click Encrypt. This uses AES-256-GCM (via the Web Crypto API) with a randomly generated key, producing the exact format Bastion stores and transmits. In a real client, the key would be derived from the sender/recipient key exchange rather than generated randomly.

Recovery Codes

At registration, Bastion generates 8 one-time recovery codes and returns them once. They are never stored in recoverable form. Each code is a 32-character hex string (a UUID with hyphens stripped). Before storage, each code is hashed with BCrypt (cost factor 10+), so a database breach exposes only the hashes, not the codes themselves.

To recover an account, the user provides one plain-text code. The server iterates over the stored BCrypt hashes calling passwordEncoder.matches(plainCode, hash) until a match is found. The matched code is marked as used (consumed forever), the new public key is installed, keyRotatedAt is set (invalidating all prior sessions), and a fresh JWT is issued.

Recovery codes can be refreshed at any time while authenticated via POST /api/auth/refresh-recovery-keys, which invalidates all existing codes and generates a fresh set of 8.

Sample recovery codes (illustrative — not real)
Code 1 of 8a3f9e2c1b4d5e6f7890a1b2c3d4e5f6a
Code 2 of 8d1e2f3a4b5c6d7e8f9a0b1c2e3f4a5b6
Code 3 of 8e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
Code 4 of 8f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6
Code 5 of 8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4
Code 6 of 8b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8
Code 7 of 8c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2
Code 8 of 8d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7

Each code is 32 hex characters (UUID without hyphens). BCrypt hashes of these are what the server actually stores.