Each service authenticates the way the real one does, and you construct none of it: the corpus generates every credential, and two of Backlot's own endpoints hand them out.
Every token on this page is what backlot import --bundled produces. They are derived, not
random — hashed from the corpus's own email addresses — so these exact values are the ones on your
machine too, and the commands below are copy-pasteable against backlot serve.
curl -s localhost:8000/_meta/users{ "org": "acme", "admin_token": "admin-service-token",
"admin_s3_access_key_id": "AKIA732S…", "admin_s3_secret_access_key": "l4sz5sXT…",
"users": [{ "email": "ava.chen@acme.com", "name": "Ava Chen",
"token": "usr-29b84da5703116c2a832",
"s3_access_key_id": "AKIADNLO…", "s3_secret_access_key": "0FdhlUUQ…",
"groups": ["handbook", "design", "product", "engineering"] }] }curl -s localhost:8000/_meta/credentials # for a Google client that wants a config, not a token{ "org": "acme", "token_uri": "http://localhost:8000/oauth2/token",
"oauth_client": { "client_id": "e8ae7a3acfbfffb01ddab8df6961c15d.apps.googleusercontent.com",
"client_secret": "GOCSPX-…" },
"service_account": { "type": "service_account", "client_email": "…", "private_key": "…" } }Both serve working credentials in the clear, and always answer: backlot mcp --user <email>
resolves a person to their credentials through /_meta/users, so a switch that closed it would
take that command's only credential input with it. The same values are in data/tokens.yaml.
| Identity | Token | Sees |
|---|---|---|
| admin / service | admin-service-token |
Everything — ACL filtering is bypassed. Use it to crawl |
ava.chen@acme.com |
usr-29b84da5703116c2a832 |
handbook, design, product, engineering |
dana.whitfield@acme.com |
usr-94ef7da374a581b973c0 |
sales |
The token is what authenticates. Atlassian's Basic is the one scheme that also checks the username, because the real service does: it has to be the token's own address (below). Send a user's token and every API filters to that user, which is what makes per-user access a test rather than an audit. Send the same request three times, changing only the token, and count what comes back:
for t in admin-service-token usr-29b84da5703116c2a832 usr-94ef7da374a581b973c0; do
curl -s localhost:8000/drive/v3/files -H "Authorization: Bearer $t" | jq '.files | length'
doneThe admin sees the whole corpus, ava sees what her four groups reach, and dana — in sales alone —
sees least. Nothing about the request changes but the identity.
| Service | Header |
|---|---|
| Slack, Microsoft Teams, Notion, HubSpot, Fireflies | Authorization: Bearer <token> |
| Gmail, Google Drive/Docs/Sheets/Slides | Authorization: Bearer <token>, or OAuth <token> |
| GitHub | Authorization: Bearer <token>, or the legacy token <token> |
| Jira, Confluence | HTTP Basic with the token as the password, or a plain Bearer |
| Linear | A bare Authorization: <token>; Bearer <token> is a 400 |
| Amazon S3 | AWS SigV4, SigV4a or Signature Version 2, signed with the identity's key pair |
| A Google client with a config | Exchange it at POST /oauth2/token — see the Google section |
Notion, HubSpot, Linear and Fireflies sit behind Cloudflare, which answers a request carrying
Authorization twice, or one of If-None-Match, If-Match, If-Modified-Since,
If-Unmodified-Since or If-Range twice, with a 400 HTML page of its own, there and here.
curl -s localhost:8000/slack/api/auth.test \
-H "Authorization: Bearer usr-29b84da5703116c2a832"The scheme is required: a bare token answers not_authed. As with the real Web API, an auth failure
is HTTP 200 with ok: false — not_authed when the header is missing or unscheme'd,
invalid_auth when the token resolves to nobody. The token is also accepted as a token form
field, which is where Slack's own clients put it and which real reads ahead of the header. A
token query parameter is refused, since Slack stopped taking one from apps created after February
24, 2021, the kind of app Backlot answers as: invalid_auth for a non-empty value, and for an empty
one beside a token in the header or the form. A token form field, even an empty one, takes the
parameter's place, so beside one the parameter is refused only when a token is read from the header
or the form.
It is a user token, not a bot token. Slack has two kinds and they are not interchangeable; a Backlot token behaves as the first:
| Real Slack | Backlot | |
|---|---|---|
User token xoxp-… |
Acts as a person — sees every channel that person is in | What a usr-… token is |
Bot token xoxb-… |
Acts as the app — sees only channels the bot was invited to, and carries a bot_id |
Not modelled |
Two consequences worth knowing before you trust a passing test here:
search.messages,search.allandsearch.filesare user-token-only in real Slack. A bot token calling them getsnot_allowed_token_type. Backlot serves all three, so search working here says nothing about whether it will work for a connector that authenticates as a bot in production.- There is no "invite the bot to the channel" step. Backlot scopes by per-document readers — the user model — so a caller reaches every channel their ACL allows. A bot in production starts with access to nothing until it is invited, which is a failure mode Backlot cannot reproduce.
- A write needs no scope. Slack gates posting and reacting on
chat:writeandreactions:write, and refuses a token without them before it reads an argument. A Backlot token writes wherever its ACL lets it read (docs/write-overlay.md).
So if the connector under test uses a bot token in production, Backlot is the more permissive of the
two on both counts. auth.test reflects the user shape it emulates: user, user_id, team,
team_id, and no bot_id.
curl -s "localhost:8000/gmail/v1/users/me/profile" \
-H "Authorization: Bearer usr-29b84da5703116c2a832"
curl -s "localhost:8000/drive/v3/files?fields=files(id,name)" \
-H "Authorization: Bearer usr-29b84da5703116c2a832"How the header is read, spelling by spelling, and where a token in the query string is read instead,
is in
supported-sources.md;
send the token in the header. me works as the mailbox id, in any case, resolving to whoever the
token is; a user's token reads no other mailbox, and the admin token reads any.
For a connector that configures with an OAuth client or a service account rather than a raw token,
token_uri from /_meta/credentials points back at Backlot, so the client's own refresh lands
here and resolves to the same ACL:
curl -s localhost:8000/oauth2/token \
-d grant_type=refresh_token \
-d refresh_token=usr-29b84da5703116c2a832 \
-d client_id=e8ae7a3acfbfffb01ddab8df6961c15d.apps.googleusercontent.com \
-d client_secret=GOCSPX-…A user's refresh token is their bearer token, so the exchange hands the same value straight back
as the access_token. Expiry is cosmetic — a re-refresh returns the same token, so a long crawl
never breaks. A signed service-account assertion works too, with its sub claim selecting the
impersonated user; a bare service account with no sub resolves to the admin identity. See
supported-sources.md.
curl -s localhost:8000/github/user/repos \
-H "Authorization: Bearer usr-29b84da5703116c2a832"Both schemes are accepted, as GitHub accepts both. user/repos is the token's own reach, so it is
the quickest check that an identity resolves. rate_limit is the one route that answers with no
credential at all, at the anonymous limits, as real serves it; a bearer that does not resolve is
still the 401 there.
Atlassian clients send HTTP Basic with an API token as the password, so that is what Backlot takes
— and the username has to be that token's own address, which is what the real service matches
on. Measured against a real Atlassian Cloud site (GET /rest/api/3/myself) with a user API token:
email:token answers 200, while an empty password, a wrong password, and a valid token under
someone else's address all answer 401.
curl -s -u "ava.chen@acme.com:usr-29b84da5703116c2a832" \
localhost:8000/atlassian/rest/api/3/project/search
curl -s -u "ava.chen@acme.com:usr-29b84da5703116c2a832" \
localhost:8000/atlassian/wiki/rest/api/spaceThe admin/service token is the exception, and the only one: it resolves to no address, so there is
nothing to match and any username goes through. That is what lets an Atlassian client send the
placeholder its config demands — svc@example.com:admin-service-token works.
A plain Authorization: Bearer <token> is accepted too, which is easier when you are driving the
API by hand rather than through an Atlassian SDK — spelt exactly that way. The real site reads the
scheme case-sensitively and wants exactly one space after it, so bearer <token>, BEARER <token>, token <token> and Bearer <token> are not credentials to it and Backlot does not take
them either.
A Bearer it does read and cannot resolve is the one refusal Jira gives here, and it comes before
the route: 403 {"error": "Failed to parse Connect Session Auth Token"}, on every Jira path
including serverInfo. A <site>.atlassian.net reads a bearer as a Connect session JWT, and a
Backlot token is an opaque string, so that is the answer the real site gives one.
The two APIs disagree about a credential that resolves to nobody, and Backlot follows each.
Jira does not refuse it: the read is served to an anonymous caller, and since a corpus grants
its documents to the org and to groups and addresses inside it, an anonymous caller reaches none of
them — project/search answers 200 with an empty values, a search answers 200 with no
issues, and an issue answers Jira's 404. The failure is reported in the
X-Seraph-LoginReason: AUTHENTICATED_FAILED header rather than in the status, so a Basic
credential is checked on Jira by reading that header — not by the status, and not by whether
anything came back, since an empty listing is also the honest answer for a working credential over
a corpus it can read nothing in. A misspelt bearer scheme has neither signal: it is not a
credential to the site, so the answer is the anonymous one with no header at all. Check a bearer
against Confluence, or against a Jira read you know the account can see.
Confluence does refuse it, with a 403 in its own envelope, or a 401 when the Basic value is
not one username and one password (an empty password included). Measured against a public
Atlassian Cloud site and a private one on 2026-09-04, with a wrong password, an empty one, a value
that is not base64, an unknown scheme, and no header at all.
curl -s "localhost:8000/msgraph/v1.0/me/joinedTeams" \
-H "Authorization: Bearer usr-29b84da5703116c2a832"Graph reads the token after Bearer in any case, or with no scheme at all, with any run of spaces
or tabs between; Backlot reads it the same way. Measured against graph.microsoft.com in October
2026, every refusal is a 401 with error.code InvalidAuthenticationToken, the same
WWW-Authenticate: Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", client_id="00000003-0000-0000-c000-000000000000"
header, and an innerError of date, request-id and client-request-id:
| What was sent | error.message |
|---|---|
No Authorization header |
Access token is empty. |
Authorization: Bearer with nothing after it |
UnableToParseTokens |
| A JWT (three base64url segments) that names no application | NoAppIdInToken |
A token that resolves to nobody, or any token under another scheme (token …, Basic …) or with a word after it |
Protocol 'Bearer' failed to validate because The token could not be read. |
Two Authorization headers are a 400 HTML page from the front Graph sits behind, before either is
read. The credential is read before the path and the method: an unknown path, or POST/DELETE on
/me, with no valid token is the same 401. error.code is the stable contract; human-readable
messages can change.
curl -s localhost:8000/notion/v1/users/me \
-H "Authorization: Bearer usr-29b84da5703116c2a832" \
-H "Notion-Version: 2025-09-03"Notion-Version is required, on every route, as real Notion requires it on every REST request: a
request without one is answered 400 missing_version, after the 401 a request without a usable
token gets, and so is one naming a version Notion does not publish — the seven are 2021-05-11,
2021-05-13, 2021-08-16, 2022-02-22, 2022-06-28, 2025-09-03 and 2026-03-11. The value also picks the query path — POST data_sources/{id}/query is served under
2025-09-03 and later, POST databases/{id}/query under the versions before it, and each refuses
the other's versions with invalid_request_url — and the shape of a database, which carries a
data_sources array from 2025-09-03 and its properties schema inline before it.
curl -s "localhost:8000/hubspot/crm/v3/objects/contacts" \
-H "Authorization: Bearer usr-29b84da5703116c2a832"Spelt exactly that way. The real API reads the scheme case-sensitively and wants one space after
it, so bearer <token>, BEARER <token>, token <token> and Bearer <token> are refused with a
401 of category INVALID_AUTHENTICATION, there and here. Space or tab around the whole header
value is ignored by both.
Linear's personal API keys go in the header with no scheme, and a Backlot token is one:
curl -s localhost:8000/linear/graphql \
-H "Authorization: usr-29b84da5703116c2a832" \
-H "Content-Type: application/json" \
-d '{"query":"{ viewer { id name } }"}'Bearer is how Linear carries an OAuth access token. An API key sent as Bearer <token> or
bearer <token> is refused with a 400 that says to drop the prefix, there and here, before the
body is read. Linear tells an API key there by its shape, lin_api_ and forty ASCII letters and
digits, so a value in that shape draws the 400 whether or not Linear issued it; Backlot gives the
same 400 to its own tokens, which stand for API keys. Any other value is the key itself, so
Token <token> or BEARER <token> is a key that does not resolve: a 401. Space or tab around the
whole header value is ignored by both.
curl -s localhost:8000/fireflies/graphql \
-H "Authorization: Bearer usr-29b84da5703116c2a832" \
-H "Content-Type: application/json" \
-d '{"query":"{ users { name } }"}'The scheme is read in any case and followed by spaces, and space, tab or U+00A0 around the token is
ignored. A credential that is refused is a 500 with an auth_failed entry, there and here, in one
of two messages: the Bearer prefix is missing, or the key did not authenticate. No
Authorization, or an empty one, is the second.
S3 does not take a bearer token. Each identity carries an s3_access_key_id /
s3_secret_access_key pair, derived from that identity's token — which is what Backlot's verifier
resolves back to a user — so hand them to any AWS client:
import boto3, httpx
from botocore.config import Config
ava = next(u for u in httpx.get("http://localhost:8000/_meta/users").json()["users"]
if u["email"] == "ava.chen@acme.com")
s3 = boto3.client(
"s3",
endpoint_url="http://localhost:8000/s3",
aws_access_key_id=ava["s3_access_key_id"],
aws_secret_access_key=ava["s3_secret_access_key"],
region_name="us-east-1", # the region Backlot presents; others are refused
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
print([b["Name"] for b in s3.list_buckets()["Buckets"]])An unsigned request is an anonymous caller's, as on real S3, and reads nothing; what it and a refused credential get is in supported-sources.md.
Signature Version 2 is verified too, in the header and in the query, since real S3 still
verifies it; botocore signs that way with Config(signature_version="s3"), though its signature for
a bucket's own operations is the mismatch real answers it with. The two refuse differently and each
refuses as real does. Reading an object's ?annotation takes SigV4 or SigV4a, as on real
(supported-sources.md).
So is SigV4a (AWS4-ECDSA-P256-SHA256), in the header and in the query, with the key botocore's
CRT signer derives from the same secret: install awscrt and pass
Config(signature_version="s3v4a"). The region set it signs has to name us-east-1.
Path addressing is not optional. Backlot serves /s3/{bucket}/{key}, so virtual-hosted
addressing — which puts the bucket in the host, acme-artifacts.localhost:8000 — reaches nothing.
boto3's default picks path for this endpoint today, so the setting looks redundant right up until
someone carries a virtual-hosted config over from real S3.
The pair is read at runtime rather than pasted here, and that is worth knowing rather than a
formality: these keys are AWS-shaped on purpose — AKIA + 16, and a 40-character secret —
because botocore validates the shape before it signs. Secret scanners cannot tell them from live
credentials, so GitHub push protection rejects a commit containing one. Keep them out of anything
you commit, yours included.
One script per service, driving the vendor's own SDK against Backlot:
examples/using-official-sdk/.