From the call yesterday, here’s a sketch of an actor authentication credential. It’s adjacent to space credentials and service auth.
Summary
com.atproto.server.createActorAuth is an XRPC procedure that lets an authorized identity obtain a short-lived token to perform a single XRPC request as another identity. The canonical case is a community moderator writing a record into the community account’s repository without ever holding the community’s credentials.
The method is the delegated counterpart to com.atproto.server.getServiceAuth. Where getServiceAuth mints inter-service auth tokens for the caller’s own identity, createActorAuth mints them for an identity the caller is authorized to act for, binds them to the exact request being made, and records the true actor in the token. Use getServiceAuth to call a service as yourself; use createActorAuth to make one specific request as an identity you are authorized to act for.
Motivation
Shared identities exist today as community accounts, organization accounts, bots operated by a team, etc. Acting as one currently means sharing credentials, usually an app password passed among moderators. That approach has no attribution, no scoping, and revoking one person means rotating everyone.
Group and community work needs members to perform actions as the group identity under policy. The requirements this proposal targets:
- Attributed: the true actor is recorded in the credential.
- Narrow: one method, one request, a validity window measured in seconds.
- Policy-checked at issuance, with the full request content visible to the policy engine.
- Verifiable by receiving services with no new infrastructure beyond what inter-service auth already requires.
Design overview
Three parties participate:
- The actor: the identity requesting to act (the moderator).
- The authorizing service: the service that controls the represented identity’s
#atprotosigning key. This is typically the identity’s PDS, or a dedicated community or group service. It servescreateActorAuth. - The target service: the service that will receive the eventual request, named in
aud.
The flow has two steps. First, the actor sends the fully formed target request to createActorAuth on the authorizing service, authenticated as themselves, naming the represented identity, the target service and the target method in headers. The authorizing service authenticates the actor, evaluates policy against the actual request content, and returns a signed JWT bound to that request. Second, the actor sends the target request, byte-identical, directly to the target service, presenting the token inside a proof of its own identity.
The token is signed with the represented identity’s #atproto signing key and carries the represented identity as iss. Verification at the target service is therefore ordinary service-auth verification (resolve iss, verify the signature against the #atproto verification method, check aud, exp, and lxm) plus the binding and presentation checks defined below. The target service does not need to understand the delegation relationship at all.
Authorization policy is out of scope. How membership, roles, and permitted actions are expressed belongs to the authorizing service and to the broader Atmosphere Groups work. This proposal defines only the minting and verification mechanics.
The request
POST /xrpc/com.atproto.server.createActorAuth
createActorAuth defines no query parameters or request body of its own. The query string and body of the call are those of the target request, passed through verbatim. This gives the authorizing service the exact bytes it needs for both validation and hashing; a JSON envelope would force re-serialization and break byte-exactness. The body may be any encoding (lexicon input encoding */*). Servers MUST accept arbitrary query parameters on this endpoint; they are validated against the target method, not this one.
Method-specific inputs are carried in headers:
Authorization(required): inter-service auth proving the actor’s own identity, withlxmnamingcom.atproto.server.createActorAuth(not the target method).atproto-identity(required): DID or handle of the identity to act as. Becomes the token’siss.atproto-lxm(required): NSID of the target method.atproto-aud(required): DID of the target service. For a repository operation this is the represented identity’s PDS; for a labeler, chat service or other target it is that service.
Both parties are named rather than inferred because not every delegable method states its subject in its content. For example, com.atproto.repo.uploadBlob declares no parameters and its body is opaque bytes. Naming it also removes a circularity in the previous default for atproto-aud, which was “the represented identity’s PDS” and therefore could not be computed before the represented identity was known.
Where the content does name a subject, the two MUST agree. An authorizing service MUST refuse a request whose atproto-identity names one identity and whose body names another, since the body is replayed verbatim and the resulting token could never be spent. Content remains what policy is evaluated against.
Lexicon schemas cannot model headers, so these are protocol-level headers documented in spec text, following the precedent of atproto-proxy and atproto-accept-labelers.
On success the response is:
{ "token": "<jwt>" }
Defined errors: ActorNotAuthorized (the actor may not perform this request as iss), InvalidRequest (the request fails validation against the target lexicon or issuance policy), and MethodNotSupported (the named lxm is not eligible for delegation through this service).
Token format
The token is a JWT signed with the represented identity’s #atproto signing key, using ES256 or ES256K according to the key’s curve. The header typ MUST be actor-request+jwt. Explicit typing (RFC 8725) keeps these tokens from being confused with ordinary service-auth tokens, and means services that have not implemented this spec fail closed by rejecting the unknown type.
| Claim | Required | Description |
|---|---|---|
iss |
yes | DID of the identity the request is performed as |
act |
yes | DID of the actor that requested the token |
aud |
yes | DID of the target service |
lxm |
yes | NSID of the method being invoked |
iat |
yes | Issued-at, UNIX timestamp, seconds precision |
exp |
yes | Expiration, UNIX timestamp, seconds precision |
jti |
yes | Unique random string nonce |
qry |
when the request has a query string | unpadded base64url SHA-256 of the query string bytes (excluding the leading ?) |
bdy |
when the request has a body | unpadded base64url SHA-256 of the request body bytes |
mty |
when the request has a body | the request’s normalized Content-Type |
Hashes are computed over the exact bytes as transmitted. No canonicalization is defined; the actor replays the identical query string and body, in the style of AWS SigV4 content hashing. qry and bdy use unpadded base64url (RFC 7515 §2), as every other binary value a JWT carries does (see jkt in RFC 9449, at_hash and c_hash in OIDC, x5t#S256 in RFC 7515, etc.).
mty is the exception, and is compared after normalization rather than byte-for-byte: byte-exactness is right for a body and wrong for a header, because intermediaries and client libraries legitimately re-case media types and re-space parameters. Normalize by lowercasing the type and subtype, lowercasing parameter keys while preserving parameter values (boundary is case-sensitive), sorting parameters by key, and joining with ; and no whitespace. It is present whenever a body is, so a verifier never needs lexicon knowledge to know whether the claim was required. Without it, a token binding a blob’s bytes leaves its declared type free for the life of the window, which for an open-encoding method is most of what the request means.
The claim name is mty rather than cty deliberately because RFC 7519 §5.2 already registers cty as a JWT header parameter meaning the content type of the payload itself, and reusing the spelling across the dot for a different thing invites a confident, wrong implementation.
The act claim follows the delegation semantics of RFC 8693, carried here as a plain DID string.
Presentation
An actor-request token is never presented on its own. The actor presents it through a proof of its own identity: inter-service auth from the actor, carrying the actor-request token in an arq claim.
{
"iss": "<actor DID>",
"aud": "<target service DID>",
"iat": 1787273230,
"exp": 1787273290,
"jti": "...",
"lxm": "com.atproto.repo.putRecord",
"arq": "<the actor-request token>"
}
The outer token is ordinary service auth, typed JWT and signed with the actor’s own #atproto key. The target service verifies it as such, verifies the embedded token under the verification rules below, requires the outer iss to equal the embedded act, and performs the request as the embedded iss.
This is what makes a stolen actor-request token worth nothing rather than worth one already-approved request: presenting one requires a fresh signature from the actor’s own key, which an attacker holding only the token does not have. It follows attestation-based client authentication, and layers on top of the token without changing its claims. It also means an actor must control a signing key which is the same requirement the Authorization header on createActorAuth already imposes.
Issuance rules
The authorizing service, before minting a token:
- Authenticates the actor from the
Authorizationheader. - Reads the represented identity from
atproto-identity, and refuses if the target request’s content names a different one. - Evaluates policy with the full request in hand. Content-level policy is the point of the passthrough design: a service can express rules like “moderators may only put records that validate against these lexicons into these collections,” which is not possible when binding to an opaque client-supplied hash.
- Computes
qry,bdyandmtyover the exact received bytes and headers, and MUST include them whenever the prepared request has a query string or body. - Sets a short validity window. Lifetimes SHOULD be 60 seconds or less.
Issuance is stateless: no per-token state is required at the authorizing service. The service SHOULD log (act, iss, lxm, jti, qry, bdy) per issuance, which yields a complete delegation audit trail as a side effect.
Verification rules
The actor presents the outer token as Authorization: Bearer <jwt> on a request sent directly to the target service. It cannot be routed through the actor’s own PDS proxying, which would substitute its own service auth.
The target service verifies the outer token as ordinary inter-service auth against the actor’s key, then extracts arq. For that embedded token it performs standard service-auth verification: resolve iss, verify the signature against the #atproto verification method, confirm aud names this service, check iat and exp, and confirm lxm matches the endpoint being invoked. In addition it:
- MUST require the outer
issto equal the embeddedact. - MUST recompute the query string and body hashes from the received request and compare them to
qryandbdy, and compare the receivedContent-Type, normalized, tomty. - MUST reject a request that carries a query string, body or content type not covered by a matching claim. Without this rule, a token silently degrades into an
lxm-scoped bearer token for its validity window. - MUST enforce one-time use of the embedded
jti, tracked by the claim value, within the validity window. This matters most for non-idempotent methods such ascreateRecordwithout a caller-supplied rkey. Only the embeddedjtineed be tracked: it is the one bound to this request, so burning it defeats replay of the pair.
The act claim does not participate in authorization; the request is authorized as iss under existing rules (for repository writes, repo equals iss, so no receiver-side changes are needed there). Verifiers SHOULD record act for audit.
Worked example
@ngerakines.me (did:plc:cbkjy5n7bk3ax2wplmtjofq2) is an admin of the community @dayton-pokemon.club (did:plc:rkh7hrrprgtarfraofpbh4vt) and wants to publish a post in the community’s repository, hosted at pds.example.com.
The community identity’s DID document contains the following #atproto verification method:
{
"id": "did:plc:rkh7hrrprgtarfraofpbh4vt#atproto",
"type": "Multikey",
"controller": "did:plc:rkh7hrrprgtarfraofpbh4vt",
"publicKeyMultibase": "zDnaefD6Yud7RRJfhURCaQaJD94w1ZPShJZqdjVXmUEJY6q6w"
}
The actor’s #atproto key is a K-256 key:
{
"id": "did:plc:cbkjy5n7bk3ax2wplmtjofq2#atproto",
"type": "Multikey",
"controller": "did:plc:cbkjy5n7bk3ax2wplmtjofq2",
"publicKeyMultibase": "zQ3shS9QEN53kkT9VuhSM4tYiiUuWACZ3P3Nd8U3NDNbXyKss"
}
For reproducing this example, the corresponding private keys are the P-256 multikey z42twjghVXySWgXmJedhoTvVeBRxR7HuCmbp12N73CoVw9Zw and the K-256 multikey z3vLddkAPHRMiip76Jsb3hufp6QQNmu5pLMJPToK5VWmQYsG. They exist only for this document; never reuse them.
The actor requests the token:
POST /xrpc/com.atproto.server.createActorAuth
Authorization: Bearer <inter-service auth for did:plc:cbkjy5n7bk3ax2wplmtjofq2>
atproto-identity: did:plc:rkh7hrrprgtarfraofpbh4vt
atproto-aud: did:web:pds.example.com
atproto-lxm: com.atproto.repo.putRecord
Content-Type: application/json
{
"repo": "did:plc:rkh7hrrprgtarfraofpbh4vt",
"collection": "app.bsky.feed.post",
"rkey": "3mtkjhgfvkc2m",
"record": {
"$type": "app.bsky.feed.post",
"text": "Hello, community!",
"createdAt": "2026-08-20T23:00:00.000Z"
}
}
The bdy hash below covers these exact body bytes: two-space indentation, LF line endings, no trailing newline. The authorizing service verifies the actor’s credential, reads the represented identity from atproto-identity, confirms it matches the repo field, confirms the actor may put this record as the community, validates the body, and returns a token with the header and claims:
{
"typ": "actor-request+jwt",
"kid": "#atproto",
"alg": "ES256"
}
{
"iss": "did:plc:rkh7hrrprgtarfraofpbh4vt",
"aud": "did:web:pds.example.com",
"act": "did:plc:cbkjy5n7bk3ax2wplmtjofq2",
"iat": 1787273224,
"exp": 1787273284,
"jti": "f685a6fbdf2b69ad667a3efd6e7f5293465dafcf11617691",
"lxm": "com.atproto.repo.putRecord",
"bdy": "_9xuLwdyNovZs55aMS3aNLIsMJRsU4Z0G9TSPpfFWEI",
"mty": "application/json"
}
The compact serialization, signed over the whitespace-free header and claims in the field order above (line breaks at the dots only):
eyJ0eXAiOiJhY3Rvci1yZXF1ZXN0K2p3dCIsImtpZCI6IiNhdHByb3RvIiwiYWxnIjoiRVMyNTYifQ
.
eyJpc3MiOiJkaWQ6cGxjOnJraDdocnJwcmd0YXJmcmFvZnBiaDR2dCIsImF1ZCI6ImRpZDp3ZWI6cGRzLmV4YW1wbGUuY29tIiwiYWN0IjoiZGlkOnBsYzpjYmtqeTVuN2JrM2F4MndwbG10am9mcTIiLCJpYXQiOjE3ODcyNzMyMjQsImV4cCI6MTc4NzI3MzI4NCwianRpIjoiZjY4NWE2ZmJkZjJiNjlhZDY2N2EzZWZkNmU3ZjUyOTM0NjVkYWZjZjExNjE3NjkxIiwibHhtIjoiY29tLmF0cHJvdG8ucmVwby5wdXRSZWNvcmQiLCJiZHkiOiJfOXh1THdkeU5vdlpzNTVhTVMzYU5MSXNNSlJzVTRaMEc5VFNQcGZGV0VJIiwibXR5IjoiYXBwbGljYXRpb24vanNvbiJ9
.
eqT-287A1w1v54Fhgs6-e2pBntfS7nWoAufEiJOUPwoO_joHDi__GcNiaNpVwJ7hAjlc8KDKkh-u80w9ghYYCQ
The actor then presents it, wrapped in inter-service auth of its own:
{
"typ": "JWT",
"kid": "#atproto",
"alg": "ES256K"
}
{
"iss": "did:plc:cbkjy5n7bk3ax2wplmtjofq2",
"aud": "did:web:pds.example.com",
"iat": 1787273230,
"exp": 1787273290,
"jti": "fefaee01ba113b86f90d7420d284bccfdeac745ca583aee0",
"lxm": "com.atproto.repo.putRecord",
"arq": "eyJ0eXAiOiJhY3Rvci1yZXF1ZXN0K2p3...w9ghYYCQ"
}
The arq value is the complete actor-request token from above, elided here. The compact serialization (line breaks at the dots only):
eyJ0eXAiOiJKV1QiLCJraWQiOiIjYXRwcm90byIsImFsZyI6IkVTMjU2SyJ9
.
eyJpc3MiOiJkaWQ6cGxjOmNia2p5NW43YmszYXgyd3BsbXRqb2ZxMiIsImF1ZCI6ImRpZDp3ZWI6cGRzLmV4YW1wbGUuY29tIiwiaWF0IjoxNzg3MjczMjMwLCJleHAiOjE3ODcyNzMyOTAsImp0aSI6ImZlZmFlZTAxYmExMTNiODZmOTBkNzQyMGQyODRiY2NmZGVhYzc0NWNhNTgzYWVlMCIsImx4bSI6ImNvbS5hdHByb3RvLnJlcG8ucHV0UmVjb3JkIiwiYXJxIjoiZXlKMGVYQWlPaUpoWTNSdmNpMXlaWEYxWlhOMEsycDNkQ0lzSW10cFpDSTZJaU5oZEhCeWIzUnZJaXdpWVd4bklqb2lSVk15TlRZaWZRLmV5SnBjM01pT2lKa2FXUTZjR3hqT25KcmFEZG9jbkp3Y21kMFlYSm1jbUZ2Wm5CaWFEUjJkQ0lzSW1GMVpDSTZJbVJwWkRwM1pXSTZjR1J6TG1WNFlXMXdiR1V1WTI5dElpd2lZV04wSWpvaVpHbGtPbkJzWXpwalltdHFlVFZ1TjJKck0yRjRNbmR3YkcxMGFtOW1jVElpTENKcFlYUWlPakUzT0RjeU56TXlNalFzSW1WNGNDSTZNVGM0TnpJM016STROQ3dpYW5ScElqb2laalk0TldFMlptSmtaakppTmpsaFpEWTJOMkV6Wldaa05tVTNaalV5T1RNME5qVmtZV1pqWmpFeE5qRTNOamt4SWl3aWJIaHRJam9pWTI5dExtRjBjSEp2ZEc4dWNtVndieTV3ZFhSU1pXTnZjbVFpTENKaVpIa2lPaUpmT1hoMVRIZGtlVTV2ZGxwek5UVmhUVk16WVU1TVNYTk5TbEp6VlRSYU1FYzVWRk5RY0daR1YwVkpJaXdpYlhSNUlqb2lZWEJ3YkdsallYUnBiMjR2YW5OdmJpSjkuZXFULTI4N0ExdzF2NTRGaGdzNi1lMnBCbnRmUzduV29BdWZFaUpPVVB3b09fam9IRGlfX0djTmlhTnBWd0o3aEFqbGM4S0RLa2gtdTgwdzlnaFlZQ1EifQ
.
BFhTBxD6lVlvdxnJ_hQz9JRLjQhReWzw4s6g2ZsLTBEKS7io8vm-lEv8X4UYg0Kpr-nq4DfwtMAGEzKhTow0kg
The request itself:
POST /xrpc/com.atproto.repo.putRecord
Authorization: Bearer <outer token above>
Content-Type: application/json
<identical body bytes>
The PDS verifies the outer token against the actor’s key, verifies the embedded token as described, recomputes the body hash, confirms lxm, aud, mty and that the outer iss equals the embedded act, and writes the record into the community’s repository under existing authorization rules, since repo equals iss.
Lexicon
{
"lexicon": 1,
"id": "com.atproto.server.createActorAuth",
"defs": {
"main": {
"type": "procedure",
"description": "Create a short-lived, request-bound token to perform a single XRPC request as another identity. The represented identity, target service and target method are carried in the required atproto-identity, atproto-aud and atproto-lxm headers. The query string and body of this call are those of the target request, verbatim.",
"parameters": {
"type": "params",
"description": "The query string of the target request, passed through verbatim. This method defines no parameters of its own.",
"properties": {}
},
"input": {
"encoding": "*/*"
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["token"],
"properties": {
"token": { "type": "string" }
}
}
},
"errors": [
{ "name": "ActorNotAuthorized" },
{ "name": "InvalidRequest" },
{ "name": "MethodNotSupported" }
]
}
}
}
Relationship to existing mechanisms
The token deliberately reuses the inter-service auth vocabulary (iss, aud, exp, iat, lxm, jti) and the same signing key and verification path, so most of the verifier already exists in any service that accepts service auth. com.atproto.server.getServiceAuth covers the self-issued case; this method adds delegation and request binding. The act claim reuses the delegation semantics of RFC 8693 token exchange. Request binding by content hash follows AWS SigV4; type separation to prevent token confusion follows the DPoP pattern; the presentation binding follows attestation-based client authentication.
Security considerations
A stolen actor-request token authorizes nothing on its own. Presenting one requires a fresh signature from the actor’s own #atproto key, so an attacker holding only the token cannot spend it. Should the presentation requirement be relaxed, the fallback property is that lxm, aud, exp, qry, bdy and mty together reduce a stolen token to a single already-approved request within about a minute — provided verifiers enforce the binding rules. That enforcement requirement is why the distinct typ exists; a service that treats these as ordinary service auth would accept a different body against the same method within the window.
Issued tokens cannot be revoked. If a moderator is demoted one second after issuance, their outstanding token still works until exp. Short lifetimes are the mitigation, and the reason exp SHOULD stay at or under 60 seconds. Implementations should resist the temptation to add a revocation list: a revocation that works only when the target service happens to be the same server that minted the token fails silently everywhere else, which is worse than not offering one.
Policy runs at issuance, not at verification. The target service learns nothing about why the request was authorized, only that the represented identity’s key signed off on it. This keeps policy private and verifiers simple, at the cost of a round trip per action and a dependency on the authorizing service’s availability.
The represented identity now arrives in a caller-chosen header, which makes account existence cheap to probe: the difference between “no such identity here” and “not authorized” answers whether a server hosts a given DID. Authorizing services SHOULD collapse the two into a single ActorNotAuthorized.
Content-level scrutiny is not uniform. A record write can be validated against its lexicon; a blob cannot, and for uploadBlob the policy engine sees only a size and a declared type.
Attribution is service-level, not user-visible. The act claim gives the authorizing and target services an audit trail, but the resulting repository record is indistinguishable from one the community wrote itself. Surfacing “posted by X via Y” to users is a lexicon-level concern (for example, a via field on the record) and out of scope here.
Changes. The request: atproto-identity added and atproto-aud made required; content-based determination of the represented identity dropped. Token format: mty binds Content-Type; qry/bdy are now unpadded base64url. Presentation: the former postscript is now normative — tokens are not presented bare. Worked example re-signed.


