Agent Authorization
INK carries “Sign in with INK” on two artifacts. The authorization grant is a scoped signed capability: an issuer signs a bounded authority for one subject to present to one named service, valid for a fixed window. The authorization challenge is the request a relying party signs to ask for a grant, and the flow that turns a challenge into a sign-in. The grant is the primitive; the challenge composes it without changing its schema.
This is a primitive, not a permissions framework. There is no delegation chain, no capability algebra and no policy language. A scope entry is an opaque token the audience interprets under its own policy. Multi-hop delegation is the separate Authorization Chains draft and is out of scope here. Products build a sign-in button on this primitive; the protocol pins only the artifacts and how each side accepts or rejects them.
The Authorization Grant
A grant is a JSON object with exactly these fields and no others. Every field is required except requireVerifiedOwner.
| Field | Value |
|---|---|
protocol | The string ink/0.1. |
type | network.ink.authorization_grant, or the legacy network.tulpa.authorization_grant. A receiver accepts both. The signed bytes bind whichever spelling was sent; it is never normalized. |
issuer | The granting principal’s DID or agent id, a non-empty string of at most 512 UTF-16 code units. The issuer key signs the grant. |
subject | The principal the grant is for (the bearer), same bound. |
audience | The DID or service id the grant may be presented to, same bound. |
scope | An array of 1 to 64 distinct strings, each 1 to 128 UTF-16 code units. |
grantId | An issuer-chosen unique id, 16 to 256 UTF-16 code units. It is the replay and revocation key. |
issuedAt | A strict INK timestamp. The grant is not valid before this instant. |
expiresAt | A strict INK timestamp, strictly after issuedAt. The grant is not valid at or after this instant. |
requireVerifiedOwner | Optional boolean. When true the verifier requires the service to supply a verified owner status. Absent means no owner requirement. |
signature | The Ed25519 body signature, base64url without padding. |
{ "protocol": "ink/0.1", "type": "network.ink.authorization_grant", "issuer": "did:web:agent.example.com", "subject": "did:web:agent.example.com", "audience": "did:web:app.example.com", "scope": ["identity.assert"], "grantId": "hT4mR9...43-char-base64url", "issuedAt": "2026-07-16T12:00:00Z", "expiresAt": "2026-07-16T12:10:00Z", "signature": "<base64url Ed25519 body signature>"}Byte bound
A grant presented as raw bytes MUST be rejected as schema when it is longer than 65536 bytes, before it is decoded. The bound is generous: the largest well-formed grant is around 12 KiB of UTF-8, so a grant padded past 65536 bytes is not a legitimate presentation and need not be decoded to be refused. A verifier handed an already-decoded object applies the structural bounds instead, so the byte bound is then the responsibility of whatever layer received the bytes.
Signature
The signature covers every field except signature itself, computed over the domain-separated JCS canonicalization of the unsigned object, the same body signature scheme INK uses elsewhere (see Authentication; ink/0.1 keeps the tulpa/sign domain). The signature binds audience, subject, scope, grantId, issuedAt and expiresAt, so relabeling audience after signing fails the signature check, not the audience check.
Maximum lifetime
A grant is a short-lived bootstrap credential and its window is the primary revocation control. The ceiling is normative: expiresAt minus issuedAt MUST NOT exceed ten minutes. A longer window is out of profile and rejects as schema on the signed bytes alone, before the signature and independent of the verifier clock. Ten minutes is long enough to absorb clock skew and a slow sign-in, short enough that a grant expires on its own well before a denylist would matter. It is deliberately larger than the five-minute freshness age INK applies to a single message, because a grant covers a whole sign-in rather than one request. A verifier caller MAY tighten this ceiling for a given check; the value is clamped so it can only shorten it, never raise it above ten minutes.
Verification order
A service verifies a grant against the issuer public key and a context: its own audience, its clock now, an optional seen set, an optional revocation predicate and an optional owner status. Checks run in order and reject at the first failure with a stable reason. The signature is checked before any context decision, so a rejected grant never reveals whether its audience or window would have passed.
- Structure and byte safety (
schema). Valid UTF-8 with no lone UTF-16 surrogate escape, the schema above, the distinct-scope rule, the positive-window rule, the ten-minute ceiling and the base64url signature shape. A window over the ceiling rejects here, on the signed bytes alone. - Issuer signature (
signature). The Ed25519 signature must verify against the issuer key under RFC 8032 strict rules (small-order and non-canonical keys rejected). - Audience binding (
audience). The signedaudiencemust equal the verifying service’s own identity. This is the confused-deputy defense. - Presentation binding (
subject). When the caller supplies the authenticated presenting principal it must equal the signedsubject. When no presenter is supplied the check is skipped and the grant is a bearer artifact the audience must bind out of band. - Caller-tightened lifetime (
schema). A window longer than a caller-supplied cap shorter than the profile ceiling rejects here. Enforced after the signature, so a verifier-local policy value is never observable on an unauthenticated grant. - Validity window (
not_yet_valid,expired).nowmust fall in[issuedAt, expiresAt). - Replay (
replay). An(issuer, grantId)pair already in the service’s seen set. - Revocation (
revoked). An(issuer, grantId)pair the revocation predicate reports as revoked, even inside its window. - Owner verification (
owner_unverified). Consulted only when the grant setsrequireVerifiedOwner: true: the supplied owner status must beverified.
Verification fails closed and never throws. A now that is not a strict INK timestamp is a verifier input error and fails closed as schema, not as a window verdict.
| Reason | Meaning |
|---|---|
schema | Malformed bytes, structure, window or signature shape |
signature | Issuer signature did not verify |
audience | Signed audience is not the verifying service |
subject | Presenter is not the signed subject |
not_yet_valid | now is before issuedAt |
expired | now is at or after expiresAt |
replay | (issuer, grantId) already seen |
revoked | (issuer, grantId) on the receiver denylist |
owner_unverified | Owner status is not verified where required |
Presentation binding
The signature names subject but does not tie a presentation to that principal, so within its window a grant is a bearer artifact: whoever holds the bytes can present them. A verifier that has authenticated the presenting principal passes it in, and the grant rejects with subject when the presenter is not the signed subject. An empty or absent presenter means no authenticated presenter was established and the check is skipped; a service MUST NOT pass an empty string as an authenticated identity. When a grant arrives over INK the audience MUST verify the authenticated envelope sender equals the grant subject before acting, and a valid grant MUST NOT be exposed to any party other than its subject and issuer.
Scope portability
A scope entry is opaque and its meaning is audience-local. The token set is signed and bounded, but the primitive assigns no meaning to any token. A receiver MUST interpret a token only under its own policy and MUST NOT read a token minted for one audience as carrying the same authority at another. An unknown token grants nothing: a receiver that does not recognize a token falls back to default-deny.
Replay and revocation
Replay is receiver state, not a signed field. A service MUST record an accepted (issuer, grantId) pair atomically with acceptance, as a single check-and-insert; two concurrent presentations of the same pair MUST NOT both be accepted. The verifier only reads the seen set.
There is no revocation list in the protocol and no revocation endpoint. Short windows are the primary control. Explicit revocation is a receiver-side denylist keyed by (issuer, grantId): a service records the pair and supplies a revocation predicate at verify time, and a revoked pair is rejected even inside its window. The key is the pair rather than grantId alone because grantId is issuer-chosen, so keying on the pair keeps one issuer’s ids from colliding with another’s.
Sign in with INK
The challenge is the artifact a relying party signs to request sign-in, and the flow binds a single sign-in with the grant that answers it. It composes the grant primitive with one specialization: the issuer adopts a grantId derived from the verified challenge.
Roles
Three principals take part. The relying party (RP) is the service that wants to sign a user in; it issues the challenge and later verifies the grant it receives. The user’s agent answers, minting the grant after obtaining consent. The user is the human the agent acts for. The RP and the agent are both ordinary INK principals with published Agent Cards; no shared platform, directory or identity provider sits between them.
Challenge
A challenge is a JSON object with exactly these fields and no others. Every field is required.
| Field | Value |
|---|---|
protocol | The string ink/0.1. |
type | network.ink.authorization_challenge. A single spelling; there is no legacy dual-accept. |
rp | The relying party’s principal, at most 512 UTF-16 code units. It MUST be a bare-host did:web identifier with no path segments: did:web: followed by a host and an optional percent-encoded port. |
nonce | An RP-chosen string, 16 to 256 UTF-16 code units, minted with at least 128 bits of entropy. |
requestedScope | An array of 1 to 64 distinct strings, each a token in the registry below, and MUST include identity.assert. |
redirectUri | The completion URL, at most 2048 UTF-16 code units, under the literal prefix rule below. |
issuedAt | A strict INK timestamp. Not valid before this instant. |
expiresAt | A strict INK timestamp, strictly after issuedAt, with a window at most ten minutes. |
signature | The Ed25519 body signature, base64url without padding. |
{ "protocol": "ink/0.1", "type": "network.ink.authorization_challenge", "rp": "did:web:app.example.com", "nonce": "<128-bit base64url nonce>", "requestedScope": ["identity.assert", "profile.read"], "redirectUri": "https://app.example.com/ink/complete", "issuedAt": "2026-07-16T12:00:00Z", "expiresAt": "2026-07-16T12:05:00Z", "signature": "<base64url Ed25519 body signature>"}The rp host is one or more dot-separated labels of lowercase a-z, digits and hyphens, and its final label MUST NOT be all-numeric, which excludes IPv4 literals structurally; internationalized names MUST be pre-encoded as punycode A-labels so comparison is bytewise. An optional port is %3A (uppercase) followed by a decimal 1 to 65535 with no leading zeros; an explicit port 443 is out of profile because the derived origin would collide with the default. The same 65536-byte raw bound the grant applies applies to a challenge.
Challenge verification order
The user’s agent verifies a challenge before minting a grant, rejecting at the first failure.
- Structure and byte safety (
schema). Valid UTF-8 with no lone surrogate, the schema above, the distinct-scope rule, registry membership for everyrequestedScopeentry, the requiredidentity.assertentry, the bare-hostdid:webgrammar, the positive-window rule and the ten-minute ceiling. The redirect prefix rule is checked here too, on the signed bytes alone. - RP signature (
signature). The Ed25519 signature must verify against an active signing key of the RP’s Agent Card, resolved fromrp. A revoked key MUST NOT verify a challenge, and a retired key MUST NOT verify one either: retired keys exist for historical verification and a live challenge is never historical. Key usability is evaluated at the verifier’s clocknow, never at the RP-chosenissuedAt, so an RP cannot backdate a challenge into an old key’s window. - Validity window (
not_yet_valid,expired).nowmust fall in[issuedAt, expiresAt).
The stable reasons are schema, signature, not_yet_valid and expired. A malformed clock now fails closed as schema. Verification fails closed and never throws.
Derived grant id
The identity assertion that answers a challenge derives its grantId from the verified challenge rather than carrying the nonce verbatim, because a raw nonce is not issuer-unique: two RPs can send the same nonce and one RP can reuse a nonce in a fresh window. The grantId is the base64url encoding without padding of the SHA-256 digest of, UTF-8 throughout: the domain string ink/challenge-id, then a single newline (U+000A), then the JCS canonicalization of the object with exactly the members rp, nonce, issuedAt and expiresAt copied from the verified challenge.
grantId = base64url( SHA-256( "ink/challenge-id" || 0x0A || JCS({rp, nonce, issuedAt, expiresAt}) ) )The encoded result is 43 characters, inside the grant’s 16-to-256 code unit bound, so the grant schema does not change. The derivation is deterministic and RP-verifiable: the RP holds the challenge it minted, so it recomputes the id and matches it against its outstanding sign-in context. It is issuer-unique structurally: challenges differing in rp, nonce or window derive distinct ids, so the same nonce at two RPs or reused in a fresh window yields different ids without any issuer-lifetime state.
Mint-once is normative. The agent MUST mint at most one identity assertion per derived id, recording the id atomically with minting as a single check-and-insert, and MUST refuse a challenge whose id it has already recorded. The record need only live as long as the challenge window plus clock skew.
Two grant shapes
The flow uses the one grant primitive in two shapes. Only the first is required. A grant minted under this flow, either shape, MUST omit requireVerifiedOwner; an RP MUST refuse a sign-in grant that carries it.
Identity assertion is the sign-in core and is mandatory. The agent mints a grant whose issuer and subject are both the agent principal, whose audience is the challenge rp, whose scope is identity.assert plus whichever identity-assertion tokens the user consented to and whose grantId is the derived id. It asserts “this principal signed in to that RP for this scope”. A bare sign-in is exactly ["identity.assert"]. The RP verifies it with the ordinary grant verifier against its own identity as audience, then runs a profile checklist: issuer MUST equal subject; grantId MUST equal the id derived from the challenge whose context received the completion; the assertion’s issuedAt MUST fall within the challenge’s own window; scope MUST contain identity.assert; and every scope entry MUST be an identity-assertion token that appeared in requestedScope. These are RP-local acceptance rules with no new wire reason.
Delegated capability is optional and consent-gated. When the user grants the RP a capability to use later, the agent mints a second grant whose issuer is the agent, whose subject is the RP, whose audience is the agent and whose grantId is a fresh issuer-chosen id, never the assertion’s derived id, so the RP can present it back to the agent over INK. The RP accepts it into custody but does not authorize it: it verifies structure, the issuer signature, the principal bindings, the mint-window rule and the validity window, and it MUST NOT run the audience-matches-self check and does not consult replay or revocation state. Authorization happens only when the RP later presents the grant to the agent, whose ordinary verifier then applies every check. A delegated capability inherits the ten-minute ceiling, so it is a bootstrap artifact the RP typically presents promptly to establish a durable relationship through the agent’s own pipeline.
Scope registry
Outside this flow a token means only what the audience’s policy says. This flow assigns meaning to tokens only under the registry, and only when the audience has opted into it by sending or accepting a challenge. Every registry token has a shape, the grant shape it is valid in.
| Token | Shape | Meaning |
|---|---|---|
identity.assert | identity assertion | Asserts only that the issuer signed in to the audience. Grants no read, write or message access. Every identity assertion and every requestedScope MUST include it. |
profile.read | identity assertion | The RP may read the public profile fields the issuer’s Agent Card exposes, nothing else. |
agent.message.send | delegated capability | Valid only where audience is the user’s agent and subject is the RP. The RP may send INK messages to the agent for consideration, subject to the agent’s normal intake rules. |
Unknown tokens grant nothing, and a token in a grant of the wrong shape grants nothing: an agent.message.send inside an identity assertion authorizes no messages. New tokens arrive only by a spec revision, never invented ad hoc. On the request side the rule is stricter than default-deny: every requestedScope entry MUST be a registry token, and a challenge carrying any unregistered entry rejects as schema. A future token is unknown to an older agent and rejects, so a rejected challenge is visible to the RP rather than silently narrowed.
Relying party requirements
The RP origin is derived from rp deterministically: https:// plus the host, plus : and the decimal port when present. The RP publishes its Agent Card at that origin’s well-known path, and the challenge signature MUST verify against an active signing key of that card. The fetch MUST pass the private-hostname classification, MUST refuse redirects at the transport layer and MUST pin the connection to the resolved address it checked, because the card is fetched from attacker-supplied bytes before any signature check. The fetched card’s agentId MUST equal rp. An RP that cannot resolve the issuer to a usable key MUST reject the sign-in rather than skip verification.
The redirectUri MUST consist of the derived origin followed immediately by /, then an optional path and query, checked as a literal string prefix match with no URL parsing. It MUST NOT contain #, \, any ASCII control character or ASCII whitespace; a value failing any rule rejects as schema before the signature. The prefix rule makes userinfo, host case tricks, percent-encoded host confusion and default-port aliasing structurally impossible.
Four RP-side properties close the sign-in against theft:
- Consume at the completion endpoint. The endpoint
redirectUrinames MUST itself consume the grant. It MUST NOT be or chain a redirect and MUST NOT forward the grant bytes cross-origin in any form (URL, fragment, body or header). An open redirect or forwarding endpoint inside the origin is the classic hook for collecting another party’s sign-in. - Context binding. An RP MUST associate each nonce with the sign-in context that initiated the challenge and MUST accept the answering grants only in that same context. Over a browser redirect no authenticated presenter exists, so presentation binding never fires; context binding is what stops a stolen grant from being replayed from another context.
- Expire the context. An RP MUST expire the sign-in context at the challenge’s
expiresAt, so a completion arriving after expiry finds no context that owns the derived id, whatever the grant’s own window says. - Bind acceptance to the derived id. Acceptance is admitted only when the grant’s
grantIdequals the id the RP derives from the challenge that owns the receiving context, and the accepted(issuer, grantId)pair is recorded atomically so a second presentation is a replay.
The agent MUST obtain a consent decision naming the RP identity and the requested scope tokens before minting any grant, including a bare identity.assert assertion, because even bare sign-in discloses the user’s stable principal. The agent MUST NOT silently mint any grant, and never a capability grant by default.
Conformance
The authorization-grant and agent-authorization categories of the ink.conformance.v1 corpus pin these decisions. Both are the capability-gated authorization profile, required only when an implementation accepts grants or challenges. Each vector carries the artifact plus its verification context, and both the TypeScript reference and the Go implementation must make the same accept or reject decision and pin the same typed reason, so the two agree on verify order. A separate set of derive-only vectors pins the exact challenge-derived grantId for fixed inputs, so both implementations compute the identical id.