Skip to main content

Clerk API Token & Passcode System

Authentication mechanisms for external applications (Chrome extension, OpenClaw, mobile app) to access Syntropy Journals API endpoints.

Two Mechanisms

MechanismLifetimeUse CaseHow Issued
API TokenPersistent (optional expiry)Ongoing API access (Chrome extension, CLI)Settings → API Tokens, or make admin-token
PasscodeShort-lived (10 min default)Device pairing, one-time verificationTriggered by authenticated user in-app

API Tokens

Architecture

User (authenticated via Clerk)
  → Settings → API Tokens → "Create Token"
  → issue_token_for_user(user_id, name, expires_in_days)
  → Generates: {prefix}{short_token}_{long_token}
     ├── short_token: stored plaintext, indexed for O(1) lookup
     └── long_token: only SHA-256 hash stored (split-token pattern)
  → Returns full token string ONCE (never retrievable again)

External App
  → Authorization: Bearer sj_abc123_xyzLongSecret...
  → verify_token(token_string) → TokenVerification { user_id, token_id, name }

Token Format

sj_<short_token>_<long_token>
│   │              │
│   │              └── long_token (base64url, 32 bytes entropy)
│   └── short_token (hex, 6 bytes = 12 chars, indexed for lookup)
└── prefix (configurable, default "sj_")

Database Table

clerk_api_tokens — stores token metadata + hashes (never plaintext):
ColumnTypeNotes
idUUIDPrimary key
user_idVARCHAR(255)Clerk user ID, indexed
short_tokenVARCHAR(50)Unique, indexed — used for O(1) lookup
long_token_hashVARCHAR(64)SHA-256 of the secret portion
nameVARCHAR(255)User-facing label
is_activeBOOLFalse when revoked
expires_atTIMESTAMPTZNull = never expires
last_used_atTIMESTAMPTZUpdated on each verification

API Endpoints

# Create token (requires existing valid token — bootstrap with make admin-token)
POST /api/ext/tokens
Authorization: Bearer sj_...
Body: { "name": "My Token", "expires_in_days": 30 }

# List tokens (masked, no secrets)
GET /api/ext/tokens
Authorization: Bearer sj_...

# Revoke token
DELETE /api/ext/tokens/{token_id}
Authorization: Bearer sj_...

Local Dev: Issue First Token

# Issue a token for the demo user (no running server needed)
make admin-token

# Custom options
make admin-token-custom ARGS="--name 'QA Token' --expires 7 --user-id user_demo_2abc3def"
Script: scripts/issue_admin_token.py

FastAPI Integration

from reflex_clerk_api import validate_api_token, TokenVerification
from fastapi import Depends

@router.get("/protected")
def my_endpoint(auth: TokenVerification = Depends(validate_api_token)):
    # auth.user_id, auth.token_id, auth.name
    return {"user": auth.user_id}

6-Digit Passcode

Architecture

Syntropy Journals (authenticated session)
  → User clicks "Pair Device"
  → issue_passcode(self, "user@email.com", channel="openclaw")
  → Generates 6 random digits (cryptographic: secrets.randbelow)
  → SHA-256 hash stored in clerk_passcodes table
  → Plaintext code shown to user (or sent via email/SMS)
  → Existing unused passcodes for same user+channel invalidated

External App (OpenClaw, mobile)
  → User enters 6-digit code
  → POST /auth/passcodes/verify
    Body: { "code": "847291", "user_identifier": "user@email.com", "channel": "openclaw" }
  → verify_passcode() → constant-time hash comparison
  → Returns: { user_id, passcode_id, user_identifier, channel }
  → Passcode marked as used (single-use)

Security Properties

  • No plaintext storage — only SHA-256 hash in DB
  • Constant-time comparisonsecrets.compare_digest prevents timing attacks
  • Single-use — verified passcode immediately marked is_used=True
  • Auto-invalidation — issuing a new passcode invalidates prior unused ones (same user+channel)
  • Configurable — 4-10 digits, 30s+ TTL (default: 6 digits, 10 minutes)
  • Channel-scoped"openclaw" passcodes don’t interfere with "email" verification

Database Table

clerk_passcodes:
ColumnTypeNotes
idUUIDPrimary key
user_idVARCHAR(255)Clerk user ID who requested the passcode
code_hashVARCHAR(64)SHA-256 of the plaintext code
user_identifierVARCHAR(255)Email/phone used for matching, indexed
channelVARCHAR(50)Scoping tag (e.g., “email”, “openclaw”)
expires_atTIMESTAMPTZDefault: 10 minutes from issuance
is_usedBOOLTrue after successful verification

API Endpoint

POST /auth/passcodes/verify
Content-Type: application/json

{
  "code": "847291",
  "user_identifier": "user@email.com",
  "channel": "openclaw"
}

# Response (200):
{
  "user_id": "user_2abc3def",
  "passcode_id": "uuid-...",
  "user_identifier": "user@email.com",
  "channel": "openclaw"
}

# Response (401): Invalid, expired, or already used

Issuing Passcodes (Server-Side)

# Inside a Reflex event handler (requires authenticated session)
result = await clerk.issue_passcode(
    self,
    user_identifier="user@email.com",
    channel="openclaw",
)
# result.code = "847291" — send to user via email/SMS/display on screen
# result.expires_at = datetime (10 min from now)

External App Integration Guide (OpenClaw Example)

Option A: Direct API Token (simplest)

  1. User creates an API token in Syntropy Settings → API Tokens
  2. User pastes token into OpenClaw’s settings
  3. OpenClaw stores token and uses it for all API calls
OpenClaw → Authorization: Bearer sj_abc123_...
        → GET /api/ext/health-profile
        → GET /api/ext/diet-score

Option B: Passcode Pairing → Token Exchange

For a smoother UX where the user doesn’t copy-paste tokens:
Step 1: User clicks "Pair with OpenClaw" in Syntropy
        → issue_passcode(self, email, "openclaw")
        → Shows 6-digit code on screen

Step 2: User enters code in OpenClaw
        → POST /auth/passcodes/verify { code, email, "openclaw" }
        → Gets back user_id

Step 3: OpenClaw requests a persistent API token
        → POST /api/ext/tokens (with temporary session from Step 2)
        → Gets back sj_... token for ongoing use

Step 4: OpenClaw stores the API token locally
        → All subsequent calls use the API token

Option C: Passcode-Only (ephemeral sessions)

For apps that don’t need persistent access:
Step 1: User requests passcode in Syntropy
Step 2: User enters code in external app
Step 3: External app verifies → gets user_id
Step 4: External app uses user_id for the session (no stored token)
Step 5: Session expires, user re-pairs next time

Configuration

Token system is configured via ClerkState.set_token_config():
clerk.ClerkState.set_token_config(
    prefix="sj_",                # Token prefix
    token_code_length=32,        # 256 bits of entropy
    short_token_length=6,        # 12 hex chars for DB lookup
    passcode_length=6,           # 6-digit OTP
    passcode_ttl_seconds=600,    # 10 minutes
)
Currently configured in scripts/issue_admin_token.py for CLI token issuance.

Files

FilePurpose
libs/reflex-clerk-api/.../clerk_provider.pyissue_token_for_user, verify_token, issue_passcode, verify_passcode
libs/reflex-clerk-api/.../token_models.pyApiToken and Passcode SQLModel tables
libs/reflex-clerk-api/.../token_config.pyTokenConfig, result types, exceptions
libs/reflex-clerk-api/.../fastapi_helpers.pyvalidate_api_token, validate_passcode, create_token_router
syntropy_journals/app/api/routes/tokens.pyCRUD endpoints for token management
scripts/issue_admin_token.pyCLI tool for issuing dev tokens