When an agent receives an HTTP request, it has to decide one thing before doing anything else:
Should I actually answer this?
This sounds trivial, but it hides a deep question. A stranger knocks on your door holding an envelope. Before you let them in, you want two things:
- Who are they? — the name on the envelope might be real or might be fake.
- Are they allowed in? — even if the name is real, do they have permission to enter this particular room?
Most "authentication" tutorials mash these together. In Bindu we keep them separate because they need separate tools:
| Question | What answers it | Where it lives |
|---|---|---|
| Are you allowed to make this request? | Authentication (this page) — an access token issued by a trusted service | OAuth 2.0 / Ory Hydra |
| Is this request really from who it claims to be? | DID signing — a cryptographic signature you can only make with a secret key | See DID.md |
You almost always need both. This page is about the first one. Read this, then the DID page. Together they explain the full lifecycle of a real Bindu request.
Imagine a movie theatre. When you buy a ticket, the person at the door doesn't care what your name is — they just want proof that you paid. You hand over a paper ticket, they tear off a stub, and you walk in. The ticket is the proof. Anyone holding a valid ticket can get in — that's why it's called a bearer token. Whoever bears it (holds it) gets access.
A bearer token in HTTP works the same way. It's a random-looking string of characters. Your client attaches it to every request. The server looks at the string and decides "yes, this is a valid ticket — proceed." It doesn't interrogate you further.
A real bearer token in Bindu looks like this:
ory_at_hV2cm_iq55iipi8M53mwvQbpNwQNfTTxvJnDlOWFRYw.I8V_GL5s2afZTh_ZMpshauGpnItx7iItBc6pgVRAOVg
It's sent as an HTTP header on every request:
Authorization: Bearer ory_at_hV2cm_iq55iipi...
That's the whole "authentication" part of Bindu: the client attaches a bearer token, the server validates it, and if it's good, the request goes through.
Two things follow from "whoever holds it, gets in":
- Treat tokens like passwords. A leaked token is an open door. Don't paste them into chat apps, don't commit them to git, don't log them.
- Give them an expiration. Bindu tokens last about one hour. If a token leaks, the damage window is bounded.
Now the obvious question: where does the bearer token come from? The agent certainly doesn't hand them out — that would be like asking the movie theatre door-checker to also run the ticket booth.
Instead, Bindu uses a separate service whose whole job is issuing and validating tokens. That service is Ory Hydra — an open-source OAuth 2.0 server, battle-tested, used by lots of companies. We don't roll our own, because token issuance is easy to get subtly wrong and hard to review.
Hydra exposes itself as two different URLs:
| URL | Purpose | Who calls it |
|---|---|---|
https://hydra.getbindu.com |
Public — issues tokens to clients. Endpoints like /oauth2/token. |
Clients (your code, Postman, the gateway) |
https://hydra-admin.getbindu.com |
Admin — registers clients, looks up what a token means. Endpoints like /admin/*. |
Agents, registration scripts |
These are two listeners on the same Hydra process, backed by one shared database. Registering a client on admin is immediately visible to the token endpoint on public. That's why our flow works across two hostnames without any sync step.
Why two URLs? The admin endpoints must never be exposed to the open internet. Anyone who can reach
/admin/clientscan register new clients or read secrets. In production, admin lives on a private network; only the public URL is reachable from outside.
Here's what happens end to end when a client wants to talk to an agent.
┌─────────┐ ┌──────────────┐ ┌──────────────┐ ┌───────┐
│ Client │ │ Hydra admin │ │ Hydra public │ │ Agent │
└────┬────┘ └──────┬───────┘ └──────┬───────┘ └───┬───┘
│ │ │ │
│ 1. Register as OAuth client │ │ │
├─────────────────────────────▶│ │ │
│ │ │ │
│ 201 Created │ │ │
│◀─────────────────────────────┤ │ │
│ │ │ │
│ │ │ │
│ 2. Exchange secret for a token │ │
├────────────────────────────────────────────────────────────▶ │
│ │ │ │
│ access_token (valid ~1h) │ │ │
│◀──────────────────────────────────────────────────────────── │
│ │ │ │
│ │ │ │
│ 3. Call agent with Authorization: Bearer <token> │ │
├────────────────────────────────────────────────────────────────────────────────────▶ │
│ │ │ │
│ │ 4. Agent asks Hydra: "is this token valid?" │
│ │◀─────────────────────────────────────────────────────┤
│ │ │ │
│ │ active=true, expires in X │ │
│ ├─────────────────────────────────────────────────────▶│
│ │ │ │
│ 5. Response │ │ │
│◀────────────────────────────────────────────────────────────────────────────────────┤
│ │ │ │
Three real-world steps, each with its own shape:
- Step 1 (once per client) — you introduce yourself to Hydra. Hydra records who you are and gives you a client secret. This happens rarely — usually once when a new client is provisioned.
- Step 2 (once per hour) — you trade your secret for a short-lived bearer token. The secret is long-lived; the token isn't.
- Step 3–5 (every request) — you attach the token to each request. The agent doesn't trust the token blindly; it asks Hydra to confirm it's still valid.
Step 4 is called token introspection. It's what makes Hydra's opaque tokens safe: the agent never reads the token itself, only asks Hydra what it means.
A Bindu bearer token looks random on purpose. It's opaque — a handle, not a document. Reading the string reveals nothing about who the user is. All the meaning lives in Hydra's database.
When the agent introspects a token, Hydra returns something like:
{
"active": true,
"client_id": "did:bindu:dutta_raahul_at_gmail_com:postman:ee67868d-d4b6-...",
"sub": "did:bindu:dutta_raahul_at_gmail_com:postman:ee67868d-d4b6-...",
"scope": "openid offline agent:read agent:write",
"exp": 1776622403,
"iat": 1776618803,
"token_type": "Bearer"
}Read line by line:
active: true— Hydra still considers this token valid. If the token has expired, been revoked, or was never issued, this flips tofalseand the request is rejected.client_id/sub— the identifier of the client this token was issued for. This is usually a DID. The DID page explains why.scope— the list of permissions this token carries. Think of scope as "which rooms of the house does this ticket let you into."agent:readgives read access;agent:writegives write access.exp— Unix timestamp when the token expires. After this,activebecomesfalse.iat— when the token was issued.
The agent's middleware reads this object, decides whether to let the request through, and attaches the client_id to the incoming request's context so handlers know who's calling.
Authentication is off by default in development. To turn it on, set environment variables that tell Bindu which Hydra to talk to.
# Flip the master switch
AUTH__ENABLED=true
# We only support Hydra today
AUTH__PROVIDER=hydra
# Where your Hydra instances live
HYDRA__ADMIN_URL=https://hydra-admin.getbindu.com
HYDRA__PUBLIC_URL=https://hydra.getbindu.comThe double-underscore (__) is how Bindu flattens nested config into environment variables. AUTH__ENABLED maps to settings.auth.enabled, HYDRA__ADMIN_URL maps to settings.hydra.admin_url. You don't need to care about the mapping — just set them.
When your agent starts with these set, the middleware automatically:
- Configures itself to talk to Hydra admin for introspection.
- Rejects any incoming request without a valid
Authorization: Bearer ...header. - Attaches the introspection result to the request so downstream code knows who's calling.
Think of this like opening an account at a bank. You tell Hydra who you are, Hydra files the paperwork.
curl -X POST 'https://hydra-admin.getbindu.com/admin/clients' \
-H 'Content-Type: application/json' \
-d '{
"client_id": "did:bindu:your_email_at_example_com:your_agent:<uuid>",
"client_secret": "<pick a strong random value>",
"grant_types": ["client_credentials"],
"response_types": ["token"],
"scope": "openid offline agent:read agent:write",
"token_endpoint_auth_method": "client_secret_post"
}'A few words on each field:
client_id— your agent's name in Hydra. In Bindu, this is always a DID string (see the DID page for why). Hydra treats it as any opaque identifier; the DID machinery adds meaning on top.client_secret— your password to get tokens. Generate 32 bytes of randomness:Store it like you'd store a database password. You need it again in step 2.openssl rand -base64 32 | tr -d '=' | tr '+/' '-_'
grant_types— how you'll be getting tokens.client_credentialsmeans "I'm a server, not a human in a browser" — no login forms, no redirects, just swap a secret for a token.scope— the permissions you want your tokens to carry. Don't request scopes you don't need.token_endpoint_auth_method: client_secret_post— says you'll send the secret in the request body (HTTP POST form), as opposed to in a header. Both are valid; we usepostfor compatibility with our code.
Whenever your access token is about to expire (or the first time you need one), call:
curl -X POST 'https://hydra.getbindu.com/oauth2/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'client_id=did:bindu:your_email_at_example_com:your_agent:<uuid>' \
-d 'client_secret=<the secret from step 1>' \
-d 'scope=openid offline agent:read agent:write'Response:
{
"access_token": "ory_at_...long opaque string...",
"expires_in": 3599,
"scope": "openid offline agent:read agent:write",
"token_type": "bearer"
}The access_token is your bearer token. Copy it. Don't log it. Store it in memory for the next ~hour; refresh when it gets close to expiring.
curl --location 'http://localhost:3773/' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer ory_at_...' \
--data '{
"jsonrpc": "2.0",
"method": "message/send",
"params": { "message": { "role": "user", "content": "Hello!" } },
"id": 1
}'If the agent responds with real data, your authentication works. If it rejects with a 401 or 403, the next section explains why.
Errors are where newcomers lose the most time. Here's a map — if you see one of these, find it in the "cause" column and fix that, not something else.
| You see | Most likely cause | Fix |
|---|---|---|
401 Unauthorized, no Authorization header |
You forgot to attach the token | Add Authorization: Bearer <token> |
401 Unauthorized, introspection says active:false |
Token expired | Re-run step 2 to get a fresh token |
401 Unauthorized, introspection says token doesn't exist |
Token belongs to a different Hydra than the agent uses | Check HYDRA__ADMIN_URL — agent and client must point at the same Hydra |
invalid_client at the token endpoint |
Wrong client_secret, or wrong client_id, or the client doesn't exist on this Hydra |
Register the client first, or double-check the secret |
invalid_scope at the token endpoint |
Requesting a scope the client wasn't registered with | Either register the client with more scopes, or request less |
| Token works for one endpoint, fails for another | The agent requires a specific scope you didn't request | Request the scope (e.g. agent:write) and get a new token |
A more subtle one, worth calling out:
Symptom: introspection against one Hydra URL returns
active:true, but the agent says the token is invalid.Cause: the agent is configured to talk to a different Hydra instance than the one that issued the token. This happens when a dev machine's
HYDRA__ADMIN_URLpoints at a local Hydra while the token came from production.Fix: make sure the
HYDRA__ADMIN_URLthe agent uses points at the same Hydra where you registered and got the token. Check the agent's startup log — it prints the admin URL it's using.
Two things worth remembering:
-
Your agent's DID is published in its agent card:
curl http://localhost:3773/.well-known/agent.json
The field
agent.did(or similar) holds the DID — that's also theclient_idfor Hydra. -
Your client secret from
bindufyis saved locally in.bindu/oauth_credentials.json. Treat that file like.ssh/id_rsa— read-only, user-only, never committed.
If you've lost the client secret entirely, register a fresh one via the admin API (PUT /admin/clients/<client_id> to replace it). The PUT rotation is documented alongside the DID setup.
Authentication answers "are you allowed in?" But in a world where one agent might ask another agent to do work on behalf of yet a third agent, you need a stronger question answered: "are you really who you claim to be?" That's what DID signing handles.
Read on: DID.md.
Register a client:
curl -X POST 'https://hydra-admin.getbindu.com/admin/clients' \
-H 'Content-Type: application/json' \
-d '{ ...see step 1 above... }'Look up a client (does it exist? what metadata is set?):
curl 'https://hydra-admin.getbindu.com/admin/clients/<client_id>'Update a client (rotate secret, update metadata):
curl -X PUT 'https://hydra-admin.getbindu.com/admin/clients/<client_id>' \
-H 'Content-Type: application/json' \
-d '{ ...full client record with changes... }'Delete a client (be careful — breaks existing tokens):
curl -X DELETE 'https://hydra-admin.getbindu.com/admin/clients/<client_id>'Introspect a token (debug "is this token valid?"):
curl -X POST 'https://hydra-admin.getbindu.com/admin/oauth2/introspect' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'token=<your access token>'Generate a strong secret:
openssl rand -base64 32 | tr -d '=' | tr '+/' '-_'