CORS & Data API
Configure Cross-Origin Resource Sharing for the HTTP Data API, manage scoped tokens, and understand the security model for browser-based access.
CORS & Data API
The AxiomDB Data API gives you a handy HTTP interface to query and update data in your Postgres instances. It is a great fit for browser-based apps, serverless functions, or any client that prefers working with HTTP instead of standard TCP connections. To keep things secure, CORS policies let you control exactly which origins (websites) are allowed to access the Data API from a browser.
CORS is for HTTP only
Remember, CORS is a security mechanism enforced by web browsers specifically for HTTP requests (like those sent to the Data API). Standard Postgres TCP connections on ports 5432 or 6432 don't use CORS. So, if you're connecting via tools like psql, Prisma, or other database drivers, you don't need to worry about CORS!
Architecture
┌──────────────────────────────────────────────────────────────────┐
│ Browser / Client │
│ │
│ fetch("https://data.axiom.cloud/v1/query", { │
│ headers: { Authorization: "Bearer dtk_xxx" } │
│ }) │
└──────────────────────────┬───────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ AxiomDB Data API Gateway │
│ │
│ 1. CORS preflight (OPTIONS) │
│ └─ Check Origin against allowed origins │
│ └─ Return Access-Control-Allow-* headers │
│ │
│ 2. Token verification │
│ └─ Validate PASETO token │
│ └─ Resolve scopes and rate limits │
│ │
│ 3. Query execution │
│ └─ Execute SQL against Postgres via PgBouncer │
│ └─ Apply row limits and timeouts │
│ │
│ 4. Response │
│ └─ Return JSON response with CORS headers │
│ └─ Log to audit trail │
└──────────────────────────────────────────────────────────────────┘Configuring CORS
Allowed Origins
You can configure CORS settings at the project level. For security reasons, we only allow access from origins you explicitly list here. While we do support wildcards, we strongly advise against using them in production.
{
"cors": {
"allowed_origins": [
"https://app.example.com",
"https://staging.example.com",
"http://localhost:3000"
],
"allowed_methods": ["GET", "POST", "OPTIONS"],
"allowed_headers": ["Authorization", "Content-Type", "X-Request-Id"],
"exposed_headers": ["X-Request-Id", "X-Rate-Limit-Remaining"],
"max_age": 86400,
"allow_credentials": true
}
}Setting CORS using the CLI
axiom data-api cors set \
--project my-project \
--origins "https://app.example.com,https://staging.example.com,http://localhost:3000" \
--methods "GET,POST,OPTIONS" \
--max-age 86400Setting CORS using the API
curl -X PUT "https://api.axiom.cloud/v1/projects/prj_abc123/data-api/cors" \
-H "Authorization: Bearer ptk_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"allowed_origins": [
"https://app.example.com",
"https://staging.example.com"
],
"allowed_methods": ["GET", "POST", "OPTIONS"],
"allowed_headers": ["Authorization", "Content-Type"],
"max_age": 86400,
"allow_credentials": true
}'CORS Headers Under the Hood
The Preflight Response
When a web browser checks permissions by sending an OPTIONS preflight request, here is what AxiomDB sends back:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-Id
Access-Control-Max-Age: 86400
Access-Control-Allow-Credentials: true
Vary: OriginThe Actual Response
Once preflight is approved, for the actual GET or POST requests, AxiomDB returns:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-Id, X-Rate-Limit-Remaining
Vary: Origin
Content-Type: application/json
{...}Why we use Vary: Origin
AxiomDB always includes the Vary: Origin header to avoid caching issues when you have multiple allowed origins. This makes sure CDN caches deliver the correct CORS headers for each unique origin.
Managing Data API Tokens
Data API tokens are completely separate from your management API tokens. For security, every Data API token is scoped, revocable, named, rate-limited, and fully audited.
Token schema
{
"token_id": "dtk_7xK2mP9nQ3w8",
"name": "Frontend Read Token",
"scopes": ["query:read"],
"branches": ["br_main", "br_staging"],
"rate_limit": {
"requests_per_minute": 100,
"rows_per_query": 10000
},
"expires_at": "2026-06-01T00:00:00Z",
"created_at": "2026-01-15T10:30:00Z",
"created_by": "usr_8xK2mP",
"last_used_at": "2026-01-15T14:22:00Z",
"status": "active"
}Token scopes
| Scope | Description |
|---|---|
query:read | Lets you run SELECT queries. |
query:write | Lets you run INSERT, UPDATE, and DELETE queries. |
query:admin | Lets you run DDL and admin-level database commands. |
data:export | Lets you run bulk data exports. |
Creating a Data API Token
axiom data-api token create \
--project my-project \
--name "Frontend Read Token" \
--scopes "query:read" \
--branches "main,staging" \
--rate-limit 100 \
--rows-limit 10000 \
--expires "90d"Output:
Token created successfully.
Token ID: dtk_7xK2mP9nQ3w8
Name: Frontend Read Token
Scopes: query:read
Branches: main, staging
Rate: 100 req/min, 10000 rows/query
Expires: 2026-04-15T10:30:00Z
Token: dtk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
⚠ This token is shown once. Store it securely.Copy your token now!
We only display the actual token value once at the moment you create it. You won't be able to retrieve it later, so make sure to copy and store it somewhere safe right away. If you lose a token, you'll need to create a new one and revoke the old one.
Listing Your Tokens
axiom data-api token list --project my-projectTOKEN ID NAME SCOPES BRANCHES STATUS
dtk_7xK2mP9nQ3w8 Frontend Read Token query:read main,staging active
dtk_4nR8wP2xK9mL Backend Write Token query:write main active
dtk_aB3xK9mL7pQ4 CI Token query:admin * revokedRevoking a Token
Once you revoke a token, it becomes invalid immediately. Any new requests using that token will receive a 401 Unauthorized response.
axiom data-api token revoke \
--project my-project \
--token dtk_7xK2mP9nQ3w8Executing Queries
Running a Read Query
curl -X POST "https://data.axiom.cloud/v1/query" \
-H "Authorization: Bearer dtk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "X-Branch: main" \
-d '{
"query": "SELECT id, name, email FROM users WHERE active = $1 LIMIT $2",
"params": [true, 100]
}'Response:
{
"rows": [
{"id": 1, "name": "Alice", "email": "alice@example.com"},
{"id": 2, "name": "Bob", "email": "bob@example.com"}
],
"columns": [
{"name": "id", "type": "integer"},
{"name": "name", "type": "text"},
{"name": "email", "type": "text"}
],
"row_count": 2,
"duration_ms": 12,
"request_id": "req_4nR8wP2x"
}Running a Write Query
curl -X POST "https://data.axiom.cloud/v1/query" \
-H "Authorization: Bearer dtk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "X-Branch: main" \
-d '{
"query": "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id",
"params": ["Charlie", "charlie@example.com"]
}'Response:
{
"rows": [{"id": 3}],
"columns": [{"name": "id", "type": "integer"}],
"row_count": 1,
"duration_ms": 8,
"request_id": "req_5nP3xK7m"
}Rate Limits
To keep things running smoothly, every token has rate limits enforced directly at the API gateway:
| Limit | Default | Configurable |
|---|---|---|
| Requests per minute | 60 | Yes |
| Rows per query | 10,000 | Yes |
| Query timeout | 30s | Yes |
| Max payload size | 1MB | No |
Rate Limit Headers
Every response from the Data API includes headers to help you track your usage:
X-Rate-Limit-Limit: 100
X-Rate-Limit-Remaining: 87
X-Rate-Limit-Reset: 1705312200When You Exceed Rate Limits
HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-Rate-Limit-Limit: 100
X-Rate-Limit-Remaining: 0
X-Rate-Limit-Reset: 1705312200
{
"error": "rate_limit_exceeded",
"message": "Token dtk_7xK2mP9nQ3w8 has exceeded the rate limit of 100 requests per minute",
"retry_after": 45
}Security Model & Protections
Protecting Against SQL Injection
The Data API requires parameterized queries for all operations. We never perform raw string interpolation on the backend to ensure your database stays secure.
✅ {"query": "SELECT * FROM users WHERE id = $1", "params": [42]}
❌ {"query": "SELECT * FROM users WHERE id = 42"}Use parameters only
Make sure your queries use placeholders like $1 and $2 for variables. If you try to embed literal values directly into the query string, the API will reject the request with a 400 Bad Request response.
Row Limits
To prevent accidental data dumps, every query is subject to a maximum row limit. This defaults to 10,000 rows per query, but you can adjust it on a per-token basis up to your project's overall maximum limit.
{
"error": "row_limit_exceeded",
"message": "Query returned 15,234 rows, exceeding the limit of 10,000",
"row_count": 15234,
"row_limit": 10000
}Query Timeouts
Queries that take too long to run (default is 30 seconds) are automatically terminated to keep resources free:
{
"error": "query_timeout",
"message": "Query exceeded the timeout of 30 seconds",
"duration_ms": 30001
}Branch Isolation
You can restrict Data API tokens to specific branches. For example, a token scoped specifically to main won't be allowed to query staging:
{
"error": "forbidden",
"message": "Token dtk_7xK2mP9nQ3w8 does not have access to branch 'staging'",
"allowed_branches": ["main"]
}Masked Token Displays
Once a token is created, we always mask its value in dashboard views, CLI outputs, and logs:
dtk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxThis masking policy applies to:
- The dashboard token list
- Any API responses returning token metadata
- Project audit logs
- CLI output (except for the one-time display when you first create the token)
Audit Trail for Data API
Every operation involving Data API tokens is logged in your project's audit history:
| Event | Description |
|---|---|
data_api.token.created | Token was created |
data_api.token.revoked | Token was revoked |
data_api.token.used | Token was used for a query (sampled) |
data_api.query.executed | Query was executed (includes masked token ID) |
Example audit event:
{
"event": "data_api.token.created",
"timestamp": "2026-01-15T10:30:00Z",
"actor": {
"id": "usr_8xK2mP",
"email": "alice@example.com"
},
"details": {
"token_id": "dtk_7xK2mP9nQ3w8",
"name": "Frontend Read Token",
"scopes": ["query:read"],
"branches": ["main", "staging"],
"expires_at": "2026-04-15T10:30:00Z"
}
}Troubleshooting CORS Issues
Preflight requests getting blocked
Access to fetch at 'https://data.axiom.cloud/v1/query' from origin 'https://evil.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on
the requested resource.What went wrong: The origin trying to reach the API isn't listed in your allowed origins list.
How to fix it: Add the origin to your project's CORS configuration:
axiom data-api cors set \
--project my-project \
--add-origin "https://new-app.example.com"Missing Authorization header
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.What went wrong: The Authorization header isn't included in your allowed_headers list.
How to fix it: Update your CORS settings to include the Authorization header:
{
"allowed_headers": ["Authorization", "Content-Type"]
}Credentials not supported
The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*'
when the request's credentials mode is 'include'.What went wrong: You have allowed_origins set to a wildcard *, but allow_credentials is enabled (true). Browsers don't allow wildcard origins when using credentials.
How to fix it: List your specific allowed origins explicitly instead of using a wildcard:
{
"allowed_origins": ["https://app.example.com"],
"allow_credentials": true
}Token not sent in CORS request
What went wrong: By default, browsers don't include credentials for cross-origin requests.
How to fix it: Make sure to set credentials: 'include' in your fetch configuration:
const response = await fetch('https://data.axiom.cloud/v1/query', {
method: 'POST',
credentials: 'include',
headers: {
'Authorization': 'Bearer dtk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'SELECT * FROM users LIMIT 10',
}),
});SDK & Client Examples
JavaScript / TypeScript
import { AxiomDB } from '@axiomdb/client';
const client = new AxiomDB({
token: process.env.AXIOM_DATA_API_TOKEN,
branch: 'main',
});
const result = await client.query(
'SELECT id, name FROM users WHERE active = $1 LIMIT $2',
[true, 100]
);
console.log(result.rows);Python
import axiomdb
client = axiomdb.Client(
token=os.environ["AXIOM_DATA_API_TOKEN"],
branch="main",
)
result = client.query(
"SELECT id, name FROM users WHERE active = $1 LIMIT $2",
[True, 100]
)
for row in result.rows:
print(row)cURL
curl -X POST "https://data.axiom.cloud/v1/query" \
-H "Authorization: Bearer dtk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "X-Branch: main" \
-d '{
"query": "SELECT id, name FROM users WHERE active = $1 LIMIT $2",
"params": [true, 100]
}'Best Practices for the Data API
- Use explicit origins. Avoid using
*in your production CORS configurations. - Scope your tokens. Only grant tokens access to the specific branches they need.
- Set an expiration date. Every API token should have a lifespan.
- Prefer read-only scopes. Only grant
query:writeif the client application absolutely needs to modify data. - Keep an eye on rate limits. Set up alerts so you're notified if a token starts pushing up against its rate limits.
- Rotate tokens regularly. Include Data API tokens in your regular credential rotation schedules.
- Always use parameterized queries. Never construct queries using string interpolation with user input.
- Set reasonable row limits. Protect your database performance by preventing queries from pulling down massive datasets.
Related Pages
How is this guide?
