Documentation

For app developers

IndieKey is a standard IndieAuth server. Any client that follows the current specification works with it; this page summarizes what that means.

If you only need to know who someone is, the simplest route is indielogin.com, which handles all of the steps below for you.

1. Discover the server

Fetch the URL the user entered (following redirects) and look for rel="indieauth-metadata" in an HTTP Link header or an HTML <link> element. Fetch that URL to get the server metadata:

{
  "issuer": "https://indiekey.id/s/SITE_ID/",
  "authorization_endpoint": "https://indiekey.id/s/SITE_ID/auth",
  "token_endpoint": "https://indiekey.id/token",
  "introspection_endpoint": "https://indiekey.id/introspect",
  "revocation_endpoint": "https://indiekey.id/revoke",
  "userinfo_endpoint": "https://indiekey.id/userinfo",
  "code_challenge_methods_supported": ["S256"],
  "authorization_response_iss_parameter_supported": true,
  ...
}

Each website using this server is its own authorization server, with its own metadata URL, issuer and authorization_endpoint. The token, introspection, revocation and userinfo endpoints are shared. Always use the endpoints from the metadata you discovered for the user's website.

The metadata URL is the one RFC 8414 derives from the issuer, https://indiekey.id/.well-known/oauth-authorization-server/s/SITE_ID, so a client that starts from the issuer rather than from the user's home page finds the same document.

2. Publish client metadata

Your client_id is a URL. Serve a JSON document there so the consent screen can show your app's name and logo:

{
  "client_id": "https://app.example.com/",
  "client_name": "Example App",
  "client_uri": "https://app.example.com/",
  "logo_uri": "https://app.example.com/logo.png",
  "redirect_uris": ["https://app.example.com/callback"]
}

A redirect_uri on a different scheme, host or port from the client_id is only accepted if it is listed in redirect_uris. Apps without a metadata document still work, but the user only sees the bare URL. The older h-app microformat is not read.

3. Send the user to the authorization endpoint

https://indiekey.id/s/SITE_ID/auth?response_type=code
  &client_id=https://app.example.com/
  &redirect_uri=https://app.example.com/callback
  &state=RANDOM_STATE
  &code_challenge=BASE64URL(SHA256(VERIFIER))
  &code_challenge_method=S256
  &scope=profile create
  &me=https://user.example.net/
  • state and PKCE with S256 are required.
  • scope is optional. Leave it out if you only need to authenticate the user.
  • me is optional. The authorization endpoint already belongs to one website, so that is who the user signs in as.

The user returns to your redirect_uri with code, state and iss. Check that state matches and iss equals the issuer from the metadata.

4. Exchange the code

To get an access token, POST to the token endpoint within 60 seconds:

curl https://indiekey.id/token \
  -d grant_type=authorization_code \
  -d code=CODE \
  -d client_id=https://app.example.com/ \
  -d redirect_uri=https://app.example.com/callback \
  -d code_verifier=VERIFIER
{
  "access_token": "…",
  "token_type": "Bearer",
  "scope": "profile create",
  "expires_in": 3600,
  "refresh_token": "…",
  "me": "https://user.example.net/",
  "profile": { "name": "…", "url": "https://user.example.net/", "photo": "…" }
}

If no scope was granted, no access token is issued and only me is returned. If you only need to know who signed in, POST the same parameters to the authorization endpoint instead, which always returns just me (and profile if granted).

A scope the user's Micropub or Microsub endpoint accepts gives you a JWT that endpoint can verify itself (see verifying access tokens), lasting an hour by default, with a refresh_token to renew it. A sign-in token, with only profile or email, is an opaque string that lasts a week and has no refresh_token: ask the user to sign in again.

5. Verify the returned me

If the returned me differs from what the user entered, fetch it and confirm its metadata has the same issuer as the iss you received before trusting it. Because every website has its own issuer, this check also catches a sign-in for a website whose home page now links somewhere else.

Refreshing and revoking

curl https://indiekey.id/token \
  -d grant_type=refresh_token \
  -d refresh_token=REFRESH_TOKEN \
  -d client_id=https://app.example.com/

Refresh tokens rotate: each use returns a new one, and presenting an old one again revokes the grant. They expire after 90 days without use.

To sign out, POST token=… to https://indiekey.id/revoke. This revokes the access token and every token issued with it.

Userinfo

With a token that has the profile or email scope, GET https://indiekey.id/userinfo with an Authorization: Bearer header to fetch the current profile.

Naming the resource a token is for

Once you have discovered the user's Micropub endpoint, you can ask for a token bound to it with the resource parameter of RFC 8707, on the authorization request and again at the token endpoint. The value is the endpoint's URL, or the URL of the user's website; it may be given more than once. Anything the user's website neither is nor links to as an endpoint is refused with invalid_target.

GET https://indiekey.id/s/SITE_ID/auth?response_type=code&…
    &scope=create
    &resource=https%3A%2F%2Fuser.example.net%2Fmicropub

The consent screen names the resource, the access token's aud is the resources named rather than every endpoint the scopes cover, and a resource server introspecting the token is answered only if it is one of them. At the token endpoint, and on a refresh, resource may narrow the list but not add to it; left out, the token is for all of them.

Resource servers: introspecting with your own keys

A Micropub or media endpoint can ask https://indiekey.id/introspect whether a token is active without any credential being set up here. It proves who it is with a key pair it publishes itself, and IndieKey finds the key from the resource's identifier alone. This is a small profile of three standards, and this server is its first implementation:

  • RFC 9728 (protected resource metadata) is where the keys are published.
  • RFC 7523 §2.2 (private_key_jwt) is how the request is signed.
  • The user's website, through rel="micropub" and rel="microsub" links, is what lets a resource see the user's tokens — the same way it already names the user's authorization server.

The server advertises the method with "introspection_endpoint_auth_methods_supported": ["Bearer", "private_key_jwt"]. The Bearer credential described under verifying access tokens keeps working.

Choose a resource identifier

The identifier is an https URL with no query or fragment, on a domain name, and it decides whose tokens you may ask about:

  • Self-hosted: an endpoint on the user's own website uses the website itself, https://user.example.net/, or any URL under it, such as https://user.example.net/micropub. On a shared host, https://example.com/~alice/ covers https://example.com/~alice/micropub and nothing of /~bob/'s.
  • Hosted: a service that runs endpoints for many websites uses one identifier per website it hosts, such as https://service.example/u/alice/, where the user's home page links to https://service.example/u/alice/micropub with rel="micropub". The identifier must cover that endpoint: be it, or a path above it on the same origin.

Publish protected resource metadata

Serve a JSON document at the identifier's well-known URL: /.well-known/oauth-protected-resource inserted between the host and the path, with a trailing slash dropped from the path. For https://user.example.net/ that is https://user.example.net/.well-known/oauth-protected-resource; for https://service.example/u/alice/ it is https://service.example/.well-known/oauth-protected-resource/u/alice.

{
  "resource": "https://user.example.net/",
  "resource_name": "My website",
  "authorization_servers": ["https://indiekey.id/s/SITE_ID/"],
  "jwks_uri": "https://user.example.net/jwks.json",
  "scopes_supported": ["create", "update", "delete", "media"],
  "bearer_methods_supported": ["header", "body"]
}
  • resource must be exactly the identifier, character for character.
  • authorization_servers, if present, must include the user's issuer from the metadata you discovered. A hosted service lists the server of the website each identifier is for.
  • jwks_uri is an https URL serving your public keys as a JWK Set. It can be shared across every identifier a service uses. Each key needs a kid; RS256 (RSA, 2048 bits or more) and ES256 (P-256) are accepted.
{"keys": [{"kty": "RSA", "n": "…", "e": "AQAB", "alg": "RS256", "use": "sig", "kid": "…"}]}

Sign the introspection request

Send the token with a client assertion instead of an Authorization header. The assertion is a JWT signed with your private key, with the kid of the matching published key in its header.

POST https://indiekey.id/introspect
Content-Type: application/x-www-form-urlencoded
Accept: application/json

token=ACCESS_TOKEN
&client_id=https%3A%2F%2Fuser.example.net%2F
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=JWT
{
  "iss": "https://user.example.net/",
  "sub": "https://user.example.net/",
  "aud": "https://indiekey.id/s/SITE_ID/",
  "iat": 1793123202,
  "exp": 1793123262,
  "jti": "489bbe4bebc9ee305a19d783d1de2033"
}
  • iss, sub and the optional client_id parameter are all the resource identifier.
  • aud is the issuer of the website whose token this is, from the metadata you discovered for it — not the introspection endpoint's URL. A hosted service uses the server of the website the request is for. A list is accepted.
  • exp is at most five minutes after iat; a minute of clock skew is allowed on both. jti is unique: each assertion is accepted once.

A request that fails these checks gets HTTP 401 with {"error": "invalid_client", "error_description": "…"}, and no active field. Metadata and keys are cached for five minutes to an hour, honouring Cache-Control within that; when a signature does not verify against the cached keys, or the kid is unknown, both documents are read once more before the request is refused, so publish a new key before signing with it. Documents are fetched over https only, from public addresses, without following redirects, must be JSON, and may be at most 64 KB.

Which tokens you are told about

A good assertion says who is asking, not what it may know. The token is then looked up, and the full response is returned only if the resource is allowed to see it; anything else is {"active": false}, the same as for an unknown or revoked token, so that no answer reveals a token exists. In order:

  1. A token issued with a resource indicator is for those resources alone: the identifier must be one of them, and nothing else is consulted.
  2. Otherwise the token's website (its me) must cover the identifier — same scheme, host and port, and the identifier's path equal to the website's or under it on a segment boundary.
  3. Or the identifier must cover an endpoint the website links to with rel="micropub" or rel="microsub", as an HTML <link> in its <head> or an HTTP Link header, relative URLs resolved against the website. No other link counts; rel="authorization_endpoint", rel="token_endpoint" and rel="indieauth-metadata" name who issues tokens, not who may read them.
melinks rel="micropub" toresource identifierAllowed?
https://user.example.net/(any)https://user.example.net/yes
https://example.com/~alice/(any)https://example.com/~alice/micropubyes
https://example.com/~alice/(any)https://example.com/~bob/no
https://example.com/https://service.example/u/alice/micropubhttps://service.example/u/alice/yes
https://example.com/https://service.example/u/alice/micropubhttps://service.example/u/bob/no
https://example.com/nothing on service.examplehttps://service.example/u/alice/no

The links a website is known to have are read when it is verified and used for an hour; after that, and whenever they fail to name the resource asking, the home page is read again (at most every five minutes) before the answer is given, so a website that has just moved its endpoint is not turned away. This rule does not replace your own checks: a hosted endpoint still confirms the token's me is the website the request is for.