Live API Documentation
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.
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.
userId plus eight one-time recovery codes (shown once, never again). These codes are your only fallback if you lose your private key.
| Field | Type | Required | Description |
|---|---|---|---|
| username | string | yes | 2–50 characters, must be unique |
| publicKey | string | yes | Base64-encoded Ed25519 public key in SPKI (X.509 SubjectPublicKeyInfo) format |
{
"userId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"username": "demo_abc123",
"recoveryKeys": [
"a3f9e2c1b4d5e6f7890a1b2c",
"d1e2f3a4b5c6d7e8f9a0b1c2",
"e5f6a7b8c9d0e1f2a3b4c5d6",
"f1a2b3c4d5e6f7a8b9c0d1e2",
"a9b0c1d2e3f4a5b6c7d8e9f0",
"b3c4d5e6f7a8b9c0d1e2f3a4",
"c7d8e9f0a1b2c3d4e5f6a7b8",
"d2e3f4a5b6c7d8e9f0a1b2c3"
]
}{
"Error: ": "Username already taken"
}
/verify before expiry.
| Field | Type | Required | Description |
|---|---|---|---|
| userId | UUID | yes | The user's ID returned from registration |
{
"nonce": "550e8400-e29b-41d4-a716-446655440000",
"expiresAt": "2026-05-14T10:02:00"
}{
"Error: ": "User not found"
}
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.
| Field | Type | Required | Description |
|---|---|---|---|
| userId | UUID | yes | The user's ID |
| signature | string | yes | Base64-encoded Ed25519 signature of the UTF-8 nonce bytes |
{
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJmNDdhYzEwYi01OGNjLTQzNzItYTU2Ny0wZTAyYjJjM2Q0NzkiLCJ1c2VybmFtZSI6ImRlbW9fYWJjMTIzIiwiaWF0IjoxNzE1Njc4ODAwLCJleHAiOjE3MTU3NjUyMDB9.example_sig"
}{
"Error: ": "Invalid signature"
}
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.
| Field | Type | Required | Description |
|---|---|---|---|
| username | string | yes | The account's username |
| recoveryKey | string | yes | One of the 8 plain-text recovery codes (32 hex chars, no dashes) |
| newPublicKey | string | yes | Base64 SPKI Ed25519 public key for the new key pair |
{
"token": "eyJhbGciOiJIUzI1NiJ9..."
}{
"Error: ": "Invalid recovery key"
}
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.
| Header | Value | Required |
|---|---|---|
| Authorization | Bearer <JWT> | yes |
"Logged out successfully"{
"Error: ": "Missing or invalid Authorization header"
}No request body. Sends your demo session JWT as the Authorization header.
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.
| Field | Type | Required | Description |
|---|---|---|---|
| newPublicKey | string | yes | Base64 SPKI Ed25519 public key for the new key pair |
"Key rotated successfully. All previous sessions are now invalid."{
"Error: ": "JWT token is expired"
}
No request body. Authenticated via the Authorization: Bearer <token> header.
{
"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"
}No request body. Sends your demo session JWT as the Authorization header.
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.
| Field | Type | Required | Description |
|---|---|---|---|
| recipientId | UUID | yes | The recipient's userId |
| cipherText | string | yes | Base64-encoded encrypted message bytes. The server stores this as-is. |
| nonce | string | yes | Decryption nonce/IV (base64). Required by the recipient to decrypt. |
{
"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"
}
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.
| Parameter | Type | Description |
|---|---|---|
| contactId | UUID | The other user's userId |
[
{
"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"
}Fetches your self-conversation (messages sent to yourself). Send a message first using the playground above.
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.
| Property | Value |
|---|---|
| SockJS endpoint | {BASE_URL}/ws |
| Subscribe topic | /topic/messages/{userId} |
| Auth | CONNECT native header: Authorization: Bearer <token> |
| Triggered by | POST /api/message/send — server pushes to recipient's topic on delivery |
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.
Authorization: Bearer [demo JWT]
/api/message/send addressed to your own userId. Since you are subscribed to your topic, the push will appear above within milliseconds.
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();
userId as the recipientId in POST /api/message/send, and the returned public key (fetched separately or cached) to encrypt the message before sending.
| Parameter | Type | Required | Description |
|---|---|---|---|
| username | string | yes | Username to search for (case-insensitive) |
[
{
"userId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"username": "alice"
}
]{
"Error: ": "No users found"
}
cipherText — opaque base64 blobnonce — decryption IV (base64)deliveryStatus — PENDING / DELIVERED / READpublicKey — for signature verification onlyHow 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.
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.
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.
Each code is 32 hex characters (UUID without hyphens). BCrypt hashes of these are what the server actually stores.