Deep Dive: UCAN Authorization for Research Collaboration
Explore how User Controlled Authorization Networks enable secure, decentralized permission management for collaborative research projects.
Understanding UCAN
The difference between UCAN and an API key is where the authority lives.
An API key is a lookup handle. It means nothing on its own. The server holds a table mapping it to a set of permissions, and every check is a query against that table. OAuth adds structure to how the handle is obtained, but the shape is the same. The issuing server remains the arbiter, and it has to be reachable and consulted for anything to be decided.
A UCAN carries its authority inside itself. The token states who issued it, who it is for, exactly what it permits, and when it expires, and it is signed. A verifier checks the signature and reads the terms. In principle nothing is looked up, so nothing has to be online.
That single change is what makes delegation work. Because the token is the authority rather than a pointer to it, its holder can mint a narrower token for someone else and sign it with their own key, with no coordination with whoever issued the original. The chain of who granted what to whom is the token itself.
The cost is the mirror image. Revocation stops being instant. A key you delete from a table is dead on the next request, whereas a signed token is valid until it expires or a verifier learns it was revoked. That trade, offline verification bought with weaker revocation, is the whole design decision, and it is why short expiries matter more here than they do with API keys.
Why UCAN for Research?
Research collaboration often involves complex permission hierarchies:
- Principal investigators need full access
- Lab managers need administrative capabilities
- Graduate students need read/write access to specific datasets
- External collaborators need time limited read access
Traditional systems struggle with this complexity. UCAN makes it natural.
UCAN Structure
Ekayana uses UCAN 1.0. A token is a DAG-CBOR array of a signature and the map it signs, and that map holds a varsig header naming the algorithm next to the payload. Here is the payload of a share as the platform builds it, written out as DAG-JSON.
{
"iss": "did:bio:devnet:AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9",
"aud": "did:bio:devnet:9hSR6S7WPtxmTojgo6GG3k4yDPecgJY292j7xrsUGWBu",
"sub": "did:bio:devnet:AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9",
"cmd": "/file/read",
"pol": [["==", ".cid", "bafkreigh2a...52zy"]],
"nonce": { "/": { "bytes": "DOZX8pb8WS59F7bM" } },
"exp": 1791417600
}The issuer and the subject are the owner's wallet DID, and the audience is the recipient's. cmd names what may be done, and the policy in pol narrows it to one file. The owner's wallet signs the token, and the token's identifier is the CID of its bytes.
Delegation Chains
The real power of UCAN comes from delegation. Every hop may only narrow what the hop above it granted. Here a principal investigator lets a lab manager read and write one file, and the lab manager passes on read alone to a student. This compiles against pq-ucan 0.1.2, the crate the platform uses.
use pq_ucan::{
command::Command,
crypto::{ed25519::Ed25519Keypair, Signer},
delegation::{Delegation, Subject},
did::KeyResolver,
invocation::Invocation,
nonce::Nonce,
policy::{Policy, Selector, Statement},
time::Timestamp,
validate::{MemoryStore, Validator},
Ipld,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut rng = pq_ucan::rng::system();
let pi = Ed25519Keypair::generate(&mut rng);
let manager = Ed25519Keypair::generate(&mut rng);
let student = Ed25519Keypair::generate(&mut rng);
let service = Ed25519Keypair::generate(&mut rng);
let now = Timestamp::from_unix(1_790_812_800)?;
let cid = "bafybeigdyrexample";
// The principal investigator lets the lab manager read and write one file.
let read_or_write = Policy::new(vec![
Statement::Equal(Selector::parse(".cid")?, Ipld::String(cid.into())),
Statement::Or(vec![
Statement::Equal(Selector::parse(".action")?, Ipld::String("read".into())),
Statement::Equal(Selector::parse(".action")?, Ipld::String("write".into())),
]),
]);
let pi_grant = Delegation::builder(manager.did(), Subject::Did(pi.did()), Command::parse("/file")?)
.policy(read_or_write)
.nonce(Nonce::random(&mut rng))
.expires_at(now.plus_seconds(7 * 24 * 3600))
.sign(&pi)?;
// The lab manager passes on read only, for a day.
let student_grant = Delegation::builder(student.did(), Subject::Did(pi.did()), Command::parse("/file/read")?)
.nonce(Nonce::random(&mut rng))
.expires_at(now.plus_seconds(24 * 3600))
.sign(&manager)?;
// The student invokes read, naming the chain root first.
let read = Invocation::builder(pi.did(), Command::parse("/file/read")?)
.audience(service.did())
.arg("cid", Ipld::String(cid.into()))
.arg("action", Ipld::String("read".into()))
.proofs([*pi_grant.cid(), *student_grant.cid()])
.nonce(Nonce::random(&mut rng))
.expires_at(now.plus_seconds(60))
.sign(&student)?;
let mut store = MemoryStore::new();
store.insert(pi_grant);
store.insert(student_grant);
Validator::new(&store, now)
.resolver(&KeyResolver)
.executor(&service.did())
.validate(&read)?;
Ok(())
}Change the student's invocation to /file/write and validation fails with "proof 1: /file/read does not cover /file/write". The student was never given more than read, so no token the student signs can claim it.
How the platform uses it
The platform's own sharing is shallower than that chain. A pass is always issued by the file's owner, through /api/share/file/prepare and /api/share/file, and the owner's wallet signs it. The only second hop is a 24 hour read grant from the recipient's wallet to the browser key that signs each download, and each of those invocations lives for 60 seconds. The service validates every invocation with itself as the executor.
The service also records every pass it issues and checks it before acting, so on Ekayana revocation is immediate. DELETE /api/share/revoke/{token_hash} or POST /api/ucan/revoke ends a pass on the next request rather than at its expiry.
Best Practices
- Grant only the permissions needed. A pass names one file and the actions it allows.
- Set short expiries for sensitive operations.
- Keep the tokens a chain depends on, since an invocation names its proofs by CID.
- Plan for key changes. A wallet's
did:biois derived from its key, so a new key means a new DID, and grants made to the old one do not follow it.
Learn more in our UCAN Authorization documentation.
Ready to Get Started?
Explore our documentation to learn how to integrate Ekayana into your research workflow.