Skip to content

Platform

Authentication: API keys and JWT

Call DocBuilder with an API key or an HS256 JWT, sign the editor embed config, verify tokens in Node, PHP, Python and C#, lifetimes and key rotation.

On this page

O3O uses three kinds of credentials for three different call directions. The table shows which one applies where; the sections below cover each in detail.

Used forHow it is sentKey on the O3O serverNotes
DocBuilder /v1/*Authorization: Bearer <API key>O3O_DOCBUILDER_API_KEYSSeveral keys may be configured. GET /v1/status needs no authentication.
DocBuilder /v1/*Authorization: Bearer <JWT>O3O_DOCBUILDER_JWT_SECRETOptional; enabled only when this variable is set.
Creating an embed session POST /o3o/embed/sessionThe token field of the configO3O_EMBED_JWT_SECRETHS256 JWT that carries the editor config itself.
Other embed session requestsAuthorization: Bearer <access_token>Generated by the gate when the session is createdapi.js sends it for you; a mismatch returns 401 invalid_session_token.
Callbacks O3O sends to youX-O3O-Signature header (HMAC SHA-256)O3O_EMBED_CALLBACK_SECRET, O3O_DOCBUILDER_CALLBACK_SECRETThe reverse direction: you verify O3O's signature. See Callbacks and webhooks.

API keys for DocBuilder#

An API key is the simplest way for your server to call DocBuilder. The administrator lists keys in O3O_DOCBUILDER_API_KEYS: a comma-separated list where each item is name:key or just key. The name part helps tell apart the keys of different calling applications.

  • A key must match ^[A-Za-z0-9_-]{24,128}$; starting it with o3o_ is recommended.
  • Items containing CHANGE_ME are ignored. With an empty list every authenticated endpoint returns 401 unauthorized with detail.reason = "no_keys_configured".
  • Keys are accepted only in the Authorization header, never in the query string. The server compares keys in constant time.
  • Rate limits apply to the whole instance, not per key. See Plan limits.
BashGenerate a key and declare it in .env
# Generate a random 52-character key
echo "o3o_$(openssl rand -hex 24)"

# online/.env: several keys, one per application
O3O_DOCBUILDER_API_KEYS=crm:o3o_9f2c61d04b7e3a58c1d2e9f0a7b6c5d4e3f2a1b0c9d8e7f6,ketoan:o3o_1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7081

# Reload the configuration (run inside online/)
docker compose --env-file .env -f docker/compose.dev.yml up -d o3o-docbuilder
GET/v1/limits

The quickest way to test a key: returns the effective plan limits when the key is valid.

Auth: BearerCommunityEnterprise
Test call with an API key
curl -s http://localhost:8080/v1/limits \
  -H "Authorization: Bearer O3O_DEMO_KEY"
// Node.js 18+ (a .mjs file or "type": "module")
const res = await fetch("http://localhost:8080/v1/limits", {
  headers: { Authorization: "Bearer O3O_DEMO_KEY" }
});
console.log(res.status, await res.json());
# pip install requests
import requests

r = requests.get(
    "http://localhost:8080/v1/limits",
    headers={"Authorization": "Bearer O3O_DEMO_KEY"},
    timeout=30,
)
print(r.status_code, r.json())
<?php
$ch = curl_init('http://localhost:8080/v1/limits');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => ['Authorization: Bearer O3O_DEMO_KEY'],
    CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
echo $status, ' ', $body, PHP_EOL;
using System.Net.Http.Headers;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
var res = await http.GetAsync("http://localhost:8080/v1/limits");
Console.WriteLine($"{(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
401Missing or wrong key. detail.reason is missing, invalid or no_keys_configured.
{
  "error": {
    "code": "unauthorized",
    "message": "Thiếu hoặc sai khoá API.",
    "detail": {
      "reason": "missing"
    },
    "request_id": "req_0123456789abcdef"
  }
}

JWT for DocBuilder#

When the administrator sets O3O_DOCBUILDER_JWT_SECRET (at least 32 characters), the Authorization: Bearer header may carry an HS256 JWT instead of an API key. A value with exactly two dots is treated as a JWT. Use a JWT when you want tokens that expire on their own, or to label the calling application in the logs through sub.

Claims of a DocBuilder JWT

  • expnumberrequired
    Unix seconds. Required. Remaining lifetime at most 24 hours; 60 seconds of clock skew allowed.
  • substringoptional
    Label written to the logs, for example the calling application's name.
  • iatnumberoptional
    Issued-at time. JWT libraries usually add it automatically.
Sign a JWT and call DocBuilder
# Sign with any of the neighbouring tabs, then send it in the header
TOKEN="$(python3 sign_docbuilder_jwt.py)"
curl -s http://localhost:8080/v1/limits \
  -H "Authorization: Bearer $TOKEN"
// npm install jsonwebtoken   (a .mjs file or "type": "module")
import jwt from "jsonwebtoken";

const token = jwt.sign({ sub: "crm" }, process.env.O3O_DOCBUILDER_JWT_SECRET, {
  algorithm: "HS256",
  expiresIn: "15m" // enough for one batch of calls; 24 hours at most
});
const res = await fetch("http://localhost:8080/v1/limits", {
  headers: { Authorization: `Bearer ${token}` }
});
console.log(res.status, await res.json());
# pip install PyJWT requests
import os
import time

import jwt
import requests

now = int(time.time())
token = jwt.encode(
    {"sub": "crm", "iat": now, "exp": now + 15 * 60},  # enough for one batch of calls; 24 hours at most
    os.environ["O3O_DOCBUILDER_JWT_SECRET"],
    algorithm="HS256",
)
r = requests.get(
    "http://localhost:8080/v1/limits",
    headers={"Authorization": f"Bearer {token}"},
    timeout=30,
)
print(r.status_code, r.json())
<?php
// composer require firebase/php-jwt
require __DIR__ . '/vendor/autoload.php';

use Firebase\JWT\JWT;

$now = time();
$token = JWT::encode(
    ['sub' => 'crm', 'iat' => $now, 'exp' => $now + 15 * 60], // enough for one batch of calls; 24 hours at most
    getenv('O3O_DOCBUILDER_JWT_SECRET'),
    'HS256'
);
$ch = curl_init('http://localhost:8080/v1/limits');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
    CURLOPT_RETURNTRANSFER => true,
]);
echo curl_exec($ch), PHP_EOL;
using System.Net.Http.Headers;

// O3OJwt class from "Checking a token yourself" below; no external package needed (.NET 6+)
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
string token = O3OJwt.Sign(new { sub = "crm", iat = now, exp = now + 15 * 60 },
    Environment.GetEnvironmentVariable("O3O_DOCBUILDER_JWT_SECRET")!);

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
Console.WriteLine(await http.GetStringAsync("http://localhost:8080/v1/limits"));

JWT for the editor embed config#

When the server sets O3O_EMBED_JWT_SECRET, every embed session request must carry a token. The token payload IS the config: the document, editor and ui keys exactly as in the O3O.Editor config, plus exp and iat. When the token is valid the gate uses the config inside it and ignores same-named fields sent alongside, so the page only needs to send {token}. YOUR server signs the token; the key never reaches the browser.

Claims of an embed config JWT

  • documentobjectrequired
    Document to open: url, title, fileType, key.
  • editorobjectrequired
    mode, lang, user, callbackUrl.
  • uiobjectoptional
    Interface options implemented in v1.
  • expnumberrequired
    Unix seconds. At most 24 hours after iat (or after now when iat is absent); 60 seconds of clock skew allowed.
  • iatnumberoptional
    Recommended.
Server configurationValid tokenNo tokenBad or expired token
O3O_EMBED_JWT_SECRET setaccepted401 token_required401 invalid_token / 401 token_expired
No key, O3O_EMBED_ALLOW_UNSIGNED=1 (DEV only)token ignored, plain config usedacceptedtoken ignored, plain config used
No key, O3O_EMBED_ALLOW_UNSIGNED=0401 embed_auth_not_configured401 embed_auth_not_configured401 embed_auth_not_configured
POST/o3o/embed/session

api.js calls this endpoint when you create an O3O.Editor; the token travels in the token field of the request body.

Auth: JWTCommunityEnterprise
Sign the embed config on your server
// Your server — npm install jsonwebtoken   (a .mjs file or "type": "module")
import jwt from "jsonwebtoken";

const config = {
  document: {
    url: "https://example.com/files/hop-dong.docx",
    title: "Hợp đồng mẫu.docx",
    fileType: "docx",
    key: "hopdong-42-v7"
  },
  editor: {
    mode: "edit",
    lang: "vi",
    user: { id: "u-1001", name: "Nguyễn Văn A" },
    callbackUrl: "https://example.com/o3o/callback"
  },
  ui: { closeButton: true }
};

// Key comes from YOUR server's environment and equals O3O's O3O_EMBED_JWT_SECRET
const token = jwt.sign(config, process.env.O3O_EMBED_JWT_SECRET, {
  algorithm: "HS256",
  expiresIn: "10m" // only needs to cover page load
});

// Browser: only the token returned by your server is needed
// const editor = new O3O.Editor("o3o-editor", {
//   token,
//   events: { onError: (e) => console.error(e.code, e.message) }
// });
# pip install PyJWT
import os
import time

import jwt

now = int(time.time())
payload = {
    "document": {
        "url": "https://example.com/files/hop-dong.docx",
        "title": "Hợp đồng mẫu.docx",
        "fileType": "docx",
        "key": "hopdong-42-v7",
    },
    "editor": {
        "mode": "edit",
        "lang": "vi",
        "user": {"id": "u-1001", "name": "Nguyễn Văn A"},
        "callbackUrl": "https://example.com/o3o/callback",
    },
    "ui": {"closeButton": True},
    "iat": now,
    "exp": now + 10 * 60,  # only needs to cover page load
}
# Key comes from YOUR server's environment and equals O3O's O3O_EMBED_JWT_SECRET
token = jwt.encode(payload, os.environ["O3O_EMBED_JWT_SECRET"], algorithm="HS256")
<?php
// composer require firebase/php-jwt
require __DIR__ . '/vendor/autoload.php';

use Firebase\JWT\JWT;

$now = time();
$payload = [
    'document' => [
        'url' => 'https://example.com/files/hop-dong.docx',
        'title' => 'Hợp đồng mẫu.docx',
        'fileType' => 'docx',
        'key' => 'hopdong-42-v7',
    ],
    'editor' => [
        'mode' => 'edit',
        'lang' => 'vi',
        'user' => ['id' => 'u-1001', 'name' => 'Nguyễn Văn A'],
        'callbackUrl' => 'https://example.com/o3o/callback',
    ],
    'ui' => ['closeButton' => true],
    'iat' => $now,
    'exp' => $now + 10 * 60, // only needs to cover page load
];
// Key comes from YOUR server's environment and equals O3O's O3O_EMBED_JWT_SECRET
$token = JWT::encode($payload, getenv('O3O_EMBED_JWT_SECRET'), 'HS256');
// O3OJwt class from "Checking a token yourself" below; no external package needed (.NET 6+)
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var payload = new
{
    document = new
    {
        url = "https://example.com/files/hop-dong.docx",
        title = "Hợp đồng mẫu.docx",
        fileType = "docx",
        key = "hopdong-42-v7"
    },
    editor = new
    {
        mode = "edit",
        lang = "vi",
        user = new { id = "u-1001", name = "Nguyễn Văn A" },
        callbackUrl = "https://example.com/o3o/callback"
    },
    ui = new { closeButton = true },
    iat = now,
    exp = now + 10 * 60 // only needs to cover page load
};
// Key comes from YOUR server's environment and equals O3O's O3O_EMBED_JWT_SECRET
string token = O3OJwt.Sign(payload, Environment.GetEnvironmentVariable("O3O_EMBED_JWT_SECRET")!);

Checking a token yourself#

The O3O server is the one that decides whether a token is valid. The functions below apply the same rules as O3O (HS256, mandatory exp, 60 seconds of skew, at most 24 hours) so you can check tokens in automated tests before sending them.

Verify an HS256 JWT
// npm install jsonwebtoken   (a .mjs file or "type": "module")
import jwt from "jsonwebtoken";

export function checkToken(token, secret) {
  // HS256 only, 60 seconds of clock skew
  const payload = jwt.verify(token, secret, { algorithms: ["HS256"], clockTolerance: 60 });
  if (typeof payload.exp !== "number") throw new Error("missing exp");
  if (payload.exp - Date.now() / 1000 > 24 * 3600) throw new Error("exp is more than 24 hours away");
  return payload;
}
# pip install PyJWT
import time

import jwt


def check_token(token: str, secret: str) -> dict:
    # HS256 only, exp required, 60 seconds of clock skew
    payload = jwt.decode(token, secret, algorithms=["HS256"], leeway=60,
                         options={"require": ["exp"]})
    if payload["exp"] - time.time() > 24 * 3600:
        raise ValueError("exp is more than 24 hours away")
    return payload
<?php
// composer require firebase/php-jwt
require __DIR__ . '/vendor/autoload.php';

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

function check_token(string $token, string $secret): object
{
    // HS256 only, 60 seconds of clock skew
    JWT::$leeway = 60;
    $payload = JWT::decode($token, new Key($secret, 'HS256'));
    if (!isset($payload->exp) || $payload->exp - time() > 24 * 3600) {
        throw new UnexpectedValueException('exp missing or more than 24 hours away');
    }
    return $payload;
}
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

// Usage: dotnet run -- <token>
string token = args.Length > 0 ? args[0] : "";
JsonElement? payload = O3OJwt.Verify(token, Environment.GetEnvironmentVariable("O3O_EMBED_JWT_SECRET")!);
Console.WriteLine(payload is null ? "invalid token" : payload.Value.GetProperty("exp").ToString());

// Sign and verify HS256 JWTs without external packages (.NET 6+)
public static class O3OJwt
{
    public static string Sign(object payload, string secret)
    {
        string header = B64Url(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }));
        string body = B64Url(JsonSerializer.SerializeToUtf8Bytes(payload));
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        byte[] sig = hmac.ComputeHash(Encoding.ASCII.GetBytes(header + "." + body));
        return header + "." + body + "." + B64Url(sig);
    }

    // Returns the payload when valid, otherwise null
    public static JsonElement? Verify(string token, string secret, int leewaySeconds = 60)
    {
        try
        {
            string[] parts = token.Split('.');
            if (parts.Length != 3) return null;
            using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
            byte[] expected = hmac.ComputeHash(Encoding.ASCII.GetBytes(parts[0] + "." + parts[1]));
            if (!CryptographicOperations.FixedTimeEquals(expected, B64UrlDecode(parts[2]))) return null;
            using var header = JsonDocument.Parse(B64UrlDecode(parts[0]));
            if (!header.RootElement.TryGetProperty("alg", out JsonElement alg) || alg.GetString() != "HS256") return null;
            using var doc = JsonDocument.Parse(B64UrlDecode(parts[1]));
            JsonElement payload = doc.RootElement.Clone();
            if (!payload.TryGetProperty("exp", out JsonElement exp)) return null;
            long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
            if (now > exp.GetInt64() + leewaySeconds) return null;
            if (exp.GetInt64() - now > 24 * 3600) return null;
            return payload;
        }
        catch (FormatException) { return null; }
        catch (JsonException) { return null; }
    }

    static string B64Url(byte[] data) =>
        Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');

    static byte[] B64UrlDecode(string s)
    {
        s = s.Replace('-', '+').Replace('_', '/');
        return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
    }
}

Lifetimes#

CredentialLifetimeWhen it expires
DocBuilder JWTexp required, at most 24 hours remaining401 token_expired
Embed config JWTexp required, at most 24 hours401 token_expired when creating a session; open sessions are not affected
Embed session access_tokenO3O_EMBED_SESSION_TTL_MINUTES, 720 minutes by defaultSession becomes expired; the final save is still accepted
Download URL of an embedded document versionO3O_EMBED_RETAIN_HOURS, 24 hours by default410 link_expired
DocBuilder result filesresult_ttl_minutes of the plan: 15 minutes (community), 1,440 minutes (enterprise)410 gone

Rotating keys#

Environment variables take effect only when the container is recreated. After editing .env, run docker compose ... up -d: Compose recreates exactly the services whose configuration changed.

  1. DocBuilder API keys: no downtime

    Add the new key to O3O_DOCBUILDER_API_KEYS while KEEPING the old one, and reload o3o-docbuilder. Move each calling application to the new key. When the logs show no more requests with the old key, remove it from the list and reload again.
  2. JWT keys (O3O_DOCBUILDER_JWT_SECRET, O3O_EMBED_JWT_SECRET)

    v1 accepts ONE key of each kind, so tokens signed with the old key are rejected as soon as the new key is active. Pick a quiet hour and change the key in the signing application and in .env together. For DocBuilder you can call with an API key during the switch. For embedding, only CREATING new sessions is affected; open sessions continue with their own access_token.
  3. Callback signing keys (O3O_EMBED_CALLBACK_SECRET, O3O_DOCBUILDER_CALLBACK_SECRET)

    Let your callback receiver accept both the old and the new key, then change the key on the O3O server, and finally drop the old key from the receiver. No callback is lost this way.