IndieAuth Implementation

Django-IndieWeb provides a complete IndieAuth implementation that supports both authentication (logging into sites) and authorization (granting permissions to apps).

Overview

IndieAuth is a federated login protocol that enables users to sign in to websites using their own domain name. Django-IndieWeb implements all three IndieAuth endpoints:

  1. Authorization Endpoint (/indieweb/auth/) - Handles user consent and generates auth codes

  2. Token Endpoint (/indieweb/token/) - Exchanges auth codes for access tokens

  3. Authentication - Verifies auth codes for login-only flows

Authentication vs Authorization

Django-IndieWeb supports two different IndieAuth flows:

Authentication Only (No Scopes)

Used when logging into websites with your domain. No consent screen is required since no permissions are granted:

GET /indieweb/auth/?me=https://example.com&client_id=https://site.com&redirect_uri=...&state=...
Authorization with Scopes

Used when granting permissions to apps (like Micropub clients). Shows consent screen:

GET /indieweb/auth/?me=https://example.com&client_id=https://app.com&redirect_uri=...&state=...&scope=create+update

Common scopes include:

  • create - Create new posts

  • update - Edit existing posts

  • delete - Delete posts

  • media - Upload media files

Scope strings are normalized before display and before storage: whitespace is collapsed, duplicate tokens are removed while preserving first-seen order, and an empty or whitespace-only value is treated as no scope. Unknown scope names are intentionally preserved. IndieAuth and Micropub scopes are extension-defined, and clients may request values such as profile, media, or site-specific scopes before django-indieweb implements matching resource-server behavior.

Security Considerations

  1. HTTPS Required: Always use HTTPS in production for all IndieAuth endpoints

  2. Auth Code Timeout: Auth codes expire after 60 seconds by default

  3. Token Expiration: Access tokens expire after 24 hours by default; the Micropub endpoint rejects expired tokens with HTTP 401

  4. CSRF Protection: The consent form includes Django’s CSRF token

  5. User Authentication: Users must be logged in to approve/deny requests

  6. PKCE (RFC 7636): The authorization endpoint accepts an optional code_challenge (43-128 characters from the unreserved set [A-Za-z0-9._~-]) and code_challenge_method (S256 or plain; defaults to plain when code_challenge is sent without a method, per RFC 7636 §4.3). Malformed challenges and unsupported methods are rejected with HTTP 400 before any authorization code is issued. When an auth code was issued with a code_challenge, the token endpoint requires a matching code_verifier and rejects any mismatch, any missing verifier, any verifier submitted without a stored challenge, and any verifier outside the RFC length and character set with invalid_grant. The S256 verification computes BASE64URL-WITHOUT-PADDING(SHA256(verifier)) and compares it to the stored challenge in constant time. Auth codes issued before this change (no code_challenge stored) continue to be redeemable without a code_verifier, preserving backwards compatibility for legacy clients.

  7. client_id Validation: Submitted client_id values must be syntactically valid URLs using the http or https scheme. They must not contain a fragment delimiter (#) at all, even with no fragment content, and must not include userinfo (user:pass@). The authorization endpoint (GET, consent POST, and code-verification POST) rejects malformed values with HTTP 400 before creating an authorization code: plain-text body invalid client_id for structural failures and invalid_client for validator failures. The token endpoint rejects malformed submissions with HTTP 400 invalid_request and content type application/x-www-form-urlencoded (matching the existing missing- code case). Operators can additionally restrict which clients are allowed by setting INDIEWEB_CLIENT_ID_VALIDATOR (see Configuration); the configured callable runs at every authorization/token path and on every Micropub request, so revoking a client takes effect immediately for previously-issued tokens. A misconfigured validator (the dotted path fails to import or the callable raises) fails closed at every call site. Stored client_id values are not re-validated structurally on use, matching the redirect_uri rule; the configured validator, however, IS re-applied on use, so pre-existing tokens whose client_id no longer satisfies operator policy are rejected with HTTP 403 invalid_client on the Micropub endpoint.

  8. redirect_uri Validation: Submitted redirect_uri values must be syntactically valid URLs using the http or https scheme. They must not contain a fragment delimiter (#) at all, even with no fragment content, and must not include userinfo (user:pass@). The authorization endpoint rejects malformed values with HTTP 400 before creating an authorization code; the token endpoint rejects malformed submissions with invalid_grant. When comparing the value submitted at the token endpoint with the value stored alongside the authorization code, the scheme and host are compared case-insensitively while the path and query are compared verbatim. redirect_uri values that already contain a query (e.g. ?next=/x) are preserved when code and state are appended.

  9. Scope Issuance Semantics: The authorization endpoint normalizes scope strings before showing them on the consent screen and before storing them on the Auth row. Normalization splits on whitespace, de-duplicates while preserving first-seen order, and joins with single spaces; whitespace-only values become no scope. Unknown scopes are accepted and preserved after normalization because IndieAuth/Micropub scopes are extension-defined. The token endpoint issues the exact normalized scope stored with the auth code. If a token exchange includes a scope parameter, that submitted value is normalized and must match the stored auth-code scope exactly. A mismatch returns HTTP 400 invalid_grant with content type application/x-www-form-urlencoded and does not create or reissue a token. Omitting scope on token exchange continues to issue the stored auth-code scope. An explicitly empty scope= parameter normalizes to no scope, so it only succeeds when the auth code was issued with no scope.

  10. Per-Operation Scope Enforcement: The Micropub resource server enforces scopes per operation rather than treating create as a master scope. The W3C Micropub Recommendation (§5 Scope) allows servers to define their own granular scopes; the names below are the project’s chosen policy and follow the conventional names that reference clients (Quill, Indigenous, Micropublish) request. POST entry create requires create (the legacy alias post is still accepted); POST action=update requires update; POST action=delete requires delete; POST action=undelete requires undelete; GET ?q=source requires update (the spec does not define a separate read scope and the typical use case for q=source is “fetch a post to edit it”). GET ?q=config, GET ?q=syndicate-to, and GET with no q only require an authenticated token. Stored scope is split on whitespace and compared as an exact token, so createXYZ does not satisfy create. Scope failures return HTTP 403 with the plain-text body authorization error. The update, delete, and undelete actions and the GET ?q=source query dispatch into the configured MicropubContentHandler after the scope check succeeds.

Configuration

Configure IndieAuth behavior in your Django settings:

# Auth code expiration time in seconds (default: 60)
INDIWEB_AUTH_CODE_TIMEOUT = 60

# Access token lifetime in seconds (default: 86400, i.e. 24 hours)
INDIEWEB_TOKEN_EXPIRES_IN = 86400

# Login URL for redirecting unauthenticated users
LOGIN_URL = "/accounts/login/"

Testing IndieAuth

Test your IndieAuth implementation using:

  1. Web-based testers:

  2. Micropub clients (for authorization):

  3. Unit tests:

    def test_consent_screen_displays(client, user):
        client.login(username=user.username, password="password")
    
        response = client.get("/indieweb/auth/", {
            "me": "https://example.com",
            "client_id": "https://app.example.com",
            "redirect_uri": "https://app.example.com/callback",
            "state": "12345",
            "scope": "create"
        })
    
        assert response.status_code == 200
        assert "Authorization Request" in response.content.decode()
    

Example: Using IndieAuth with a Micropub Client

  1. Configure your site’s homepage to advertise the endpoints:

    <link rel="authorization_endpoint" href="https://mysite.com/indieweb/auth/">
    <link rel="token_endpoint" href="https://mysite.com/indieweb/token/">
    <link rel="micropub" href="https://mysite.com/indieweb/micropub/">
    
  2. When a Micropub client tries to authenticate:

    1. It discovers your endpoints from your homepage

    2. Redirects you to the authorization endpoint

    3. You see the consent screen and approve/deny

    4. The client receives an auth code

    5. The client exchanges the code for an access token

    6. The client can now create posts using the token

Troubleshooting

“Missing parameter” errors

Ensure all required parameters are provided: me, client_id, redirect_uri, state

Consent screen not showing

Check that you’re logged in and all parameters are valid

Auth code expired

Auth codes are only valid for 60 seconds. The client must exchange them quickly.

No scopes shown on consent screen

This is normal for authentication-only flows. Scopes are only shown when the client requests permissions.