Presence Proof Specification

Version: 1.0
Status: Draft


This specification defines two independent layers:

Apps MAY use the App Platform for fast development, or integrate directly with the Core Service for full control. Service registration with the Core Service is optional — proof issuance does not require registration.


Part 1: Core Service

The Core Service verifies physical taps, issues signed presence proofs, and hosts service discovery. Identity is verified on each request using AT Protocol service auth.

1. Introduction

A presence proof is a signed attestation binding an identity (DID) to a location (AT-URI on the platform repo) at a point in time. Proofs are issued by a Core Service server after verifying a tap.

Two tap mechanisms are supported, with different trust properties:

In both cases the resulting proof is bound to the visitor's DID via AT Protocol service auth (Section 3.4).

Proofs are ES256-signed JWTs. Verification is stateless — any party can verify a proof using the platform's published public key. No trust relationship, API access, or shared secret is required beyond key discovery.

2. Terminology

3. Proof format

3.1 Header

{
  "alg": "ES256",
  "typ": "JWT",
  "kid": "presence-proof-key"
}

The kid MUST match a key in the platform's JWKS (Section 5).

3.2 Payload

{
  "iss": "did:web:platform.example.com",
  "sub": "did:plc:abc123xyz",
  "loc": "at://did:web:platform.example.com/dev.atlocally.location.profile/park",
  "jti": "3kpqhzxfj24n2",
  "tapped_at": 1775846115,
  "iat": 1775846115
}

3.3 Claims

Claim Type Description
iss DID (string) Platform DID. Identifies the issuer. Verifiers use this to discover the JWKS.
sub DID (string) Visitor DID. The identity bound to this tap.
loc AT-URI (string) Location identifier. Points to the dev.atlocally.location.profile record on the platform's repo. Resolved from the tag UID at tap time.
jti TID (string) Unique proof identifier. Format is a 13-character base32-sortable TID. Used as the rkey when writing the presence record to the visitor's PDS, enabling single-record-per-tap semantics (Section 8.1).
tapped_at Unix timestamp (integer) Server-set time of the tap.
iat Unix timestamp (integer) JWT issued-at. Equal to tapped_at.

The proof does not include an exp claim. Presence proofs are archival attestations of past events, not session tokens — they are meant to remain verifiable indefinitely. Apps that need freshness (e.g., a write window) apply their own policy against tapped_at (Section 6.3).

The proof intentionally excludes location metadata (name, description). Consumers resolve this from the location's profile record (Section 9).

The proof excludes application-specific time windows. Applications enforce their own policies against tapped_at (Section 6.3).

3.4 Identity binding

The sub claim identifies who tapped. The Core Service verifies this identity using AT Protocol service auth — a short-lived JWT issued by the visitor's PDS, signed with the PDS's signing key.

The Core Service does not implement OAuth, session management, or any identity protocol. Instead, it relies on the AT Protocol's native cross-service authentication mechanism: the caller obtains a service auth token from the visitor's PDS and presents it to the Core Service. The Core Service verifies the token by resolving the visitor's DID document and checking the signature against the PDS's public key.

This means any caller — an App Platform, an independent app, a native mobile app — can authenticate a visitor to the Core Service without the Core Service maintaining any session state. The PDS is the identity authority.

4. Signing

4.1 Algorithm

ES256 (ECDSA on NIST P-256 with SHA-256), per RFC 7518 Section 3.4.

4.2 Key management

The platform's proof signing key is a P-256 keypair. The public key is published in the platform's DID document (Section 5.1), JWKS endpoint (Section 5.2), and DID log (Section 5.3).

Each key has a unique kid. Implementations SHOULD encode the rotation date (e.g., presence-proof-key-2025-06) so retired keys are unambiguously distinguishable from successors.

4.3 Key rotation

Rotation is manual and expected to be infrequent. On rotation:

Verifiers select the appropriate key by matching the JWT's kid header against JWKS or the DID log. Presence proofs are archival — their value is attesting that a tap occurred at a point in time, meaningful indefinitely for downstream copies, archives, and after-the-fact audits.

5. Key discovery

The platform exposes three endpoints for key discovery, each serving a different purpose:

Endpoint Contains Use for
/.well-known/did.json Current key only Standard did:web resolution; verifying recently-issued proofs
/.well-known/jwks.json All keys (current + retired) JWT-library key lookup by kid
/.well-known/did-log.json Append-only key history with timestamps Auditing rotations; reconstructing which key was active at a given time

5.1 DID document (canonical, current)

The platform's DID document is the canonical did:web resolution per W3C did-core. It reflects the current state of the platform identity — current verification method, current service endpoints. Past keys are not present.

GET /.well-known/did.json

→ {
    "@context": ["https://www.w3.org/ns/did/v1"],
    "id": "did:web:platform.example.com",
    "verificationMethod": [{
      "id": "did:web:platform.example.com#presence-proof-key-2025-06",
      "type": "JsonWebKey2020",
      "controller": "did:web:platform.example.com",
      "publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
    }],
    "service": [{
      "id": "#presence",
      "type": "PresenceVerificationService",
      "serviceEndpoint": "https://platform.example.com"
    }]
  }

A verifier with only the iss claim can resolve the platform DID, extract the current verification method, and verify a recently-issued proof. For proofs whose kid does not match the current key (i.e., signed with a since-retired key), verifiers consult JWKS (Section 5.2) or the DID log (Section 5.3).

This is the same mechanism the Core Service uses to verify service auth tokens (Section 7.2) — key discovery via DID documents is the standard across the entire system.

5.2 JWKS endpoint (all keys)

The JWKS endpoint serves every signing key ever used by the platform, including retired ones. This enables verification of historical proofs via the standard JWT-ecosystem flow: the verifier matches the JWT's kid header against the entries in keys.

GET /.well-known/jwks.json

→ {
    "keys": [
      {
        "kty": "EC",
        "kid": "presence-proof-key-2025-06",
        "crv": "P-256",
        "x": "...",
        "y": "...",
        "use": "sig",
        "alg": "ES256"
      },
      {
        "kty": "EC",
        "kid": "presence-proof-key-2024-01",
        "crv": "P-256",
        "x": "...",
        "y": "...",
        "alg": "ES256"
      }
    ]
  }

The current signing key carries use: "sig". Retired keys omit use, signaling that they are present for historical verification only and MUST NOT be used to validate newly-issued artifacts. Verifiers performing signature checks on a specific JWT SHOULD select the key whose kid matches the JWT header regardless of the use value.

The platform sets Cache-Control: public, max-age=3600. Verifiers SHOULD cache the JWKS for at least 1 hour and MUST NOT fetch it more than once per minute under normal operation.

5.3 DID log (key history)

The DID log is an append-only record of every signing key ever associated with the platform DID, with timestamps for when each key was added and retired. It is conceptually similar to the per-DID operation log maintained by did:plc registries (e.g., https://plc.directory/{did}/log), adapted for did:web and scoped to signing keys.

GET /.well-known/did-log.json

→ {
    "did": "did:web:platform.example.com",
    "entries": [
      {
        "kid": "presence-proof-key-2024-01",
        "publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." },
        "addedAt": "2024-01-01T00:00:00Z",
        "retiredAt": "2025-06-01T00:00:00Z"
      },
      {
        "kid": "presence-proof-key-2025-06",
        "publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." },
        "addedAt": "2025-06-01T00:00:00Z",
        "retiredAt": null
      }
    ]
  }

Entries are ordered by addedAt ascending. The most recent entry with retiredAt: null is the current signing key. Entries are never removed.

The DID log is not a W3C did-core artifact — did:web does not define an audit log endpoint. It is an atlocally-specific extension that provides PLC-style key auditability for a did:web identifier. Verifiers that only need to validate an individual proof can rely on JWKS or the DID document; the DID log is intended for auditors, for verifiers that need to reconstruct historical state with timestamps, and as the source of truth for rebuilding /.well-known/jwks.json after operator-side changes.

6. Verification

6.1 Steps (via DID document)

  1. Extract the iss claim from the JWT payload.
  2. Resolve the platform's DID document (e.g., GET {iss-as-did-web-url}/.well-known/did.json).
  3. Extract the public key from the verificationMethod matching the JWT's kid header.
  4. Verify the ES256 signature.
  5. Apply application-specific policy against tapped_at (Section 6.3).

Alternatively, verifiers MAY use the JWKS endpoint (Section 5.2) for key discovery. The verification steps are the same, substituting step 2-3 with a JWKS fetch and kid lookup.

6.2 Convenience endpoint

Verifying a proof locally requires key discovery — either DID resolution (Section 6.1) or JWKS fetching (Section 5.2). For callers that don't want to implement either, the Core Service can verify proofs directly using the signing key it already holds:

POST /xrpc/dev.atlocally.verifyProof
Content-Type: application/json

{ "proof": "<JWT>" }

→ { "valid": true, "proof": { "iss": "...", "sub": "...", "loc": "...", "tapped_at": ... } }
→ { "valid": false }

This endpoint has no auth requirement. The result is identical to local verification — the platform checks the same signature with the same key. The tradeoff is a network round-trip to the platform instead of local crypto, and a trust dependency on the platform's honesty (see Appendix C.3).

6.3 Application time policies

The proof carries no expiry claim; apps define their own freshness policies against tapped_at:

const elapsed = Math.floor(Date.now() / 1000) - proof.tapped_at;

// Guestbook: 1-hour write window, 24-hour read window
if (mode === "write" && elapsed > 3600) reject();
if (mode === "read" && elapsed > 86400) reject();

// Scavenger hunt: valid only during event
if (proof.tapped_at < eventStart || proof.tapped_at > eventEnd) reject();

// Loyalty: tap must be from today
if (new Date(proof.tapped_at * 1000).toDateString() !== new Date().toDateString()) reject();

7. Issuance

7.1 Prerequisites

7.2 Service auth

Before requesting a proof, the caller MUST obtain a service auth token from the visitor's PDS. This token is a JWT that the PDS signs with its signing key, attesting that the visitor is authenticated.

GET /xrpc/com.atproto.server.getServiceAuth
  ?aud=did:web:platform.example.com
  &lxm=<method-nsid>
  &exp=<unix timestamp, recommended: now + 60s>

→ { "token": "<service-auth-jwt>" }
Parameter Description
aud The Core Service's DID. The token is bound to this audience.
lxm Required. The lexicon method the token authorizes. Equals the NSID of the Core Service method being called (e.g., dev.atlocally.tap for a tap; dev.atlocally.createLocation for a location creation). A token with a different lxm MUST be rejected.
exp Token expiry. SHOULD be short-lived (60 seconds recommended).

Callers obtain a separate service auth token for each Core Service method they invoke. The tokens are cheap to mint (a single XRPC call to the PDS) and short-lived, so this is not a meaningful overhead.

The caller may be:

The Core Service does not care who the caller is. It verifies the service auth token.

7.3 Flow

  1. Caller obtains a service auth token from the visitor's PDS (Section 7.2).

  2. Caller submits tap parameters with the service auth token:

    POST /xrpc/dev.atlocally.tap
    Authorization: Bearer <service-auth-token>
    Content-Type: application/json
    
    { "uid": "...", "ctr": "...", "cmac": "..." }
    
  3. Core Service verifies identity:

    1. Decodes the service auth token (without verifying signature yet) to extract the visitor's DID.
    2. Resolves the visitor's DID document to obtain the PDS's signing key.
    3. Verifies the service auth token signature against the PDS's signing key.
    4. Checks that the token's aud matches the Core Service's DID.
    5. Checks that the token's lxm matches the invoked method's NSID (for this flow, dev.atlocally.tap). Tokens lacking lxm, or carrying a different lxm, MUST be rejected.
    6. Checks that the token has not expired.
  4. Core Service verifies the SUN message:

    • Validates the AES-CMAC against the stored key for this tag UID.
    • Checks the counter against a high-water mark (rejects if ≤ HWM − counterWindow).
    • Records the counter value (rejects exact duplicates).
  5. Core Service signs and returns the presence proof JWT.

  6. The caller (optionally) writes a dev.atlocally.presence record to the visitor's PDS (Section 8.1).

7.4 Why service auth

Service auth is AT Protocol's native mechanism for cross-service identity verification (used by feed generators, labelers, and other services in the AT Protocol ecosystem). It provides:

7.5 Counter window

The platform maintains a high-water mark (HWM) per tag UID. Counters are accepted if they fall within (HWM - counterWindow, ∞) and have not been seen before. The default counterWindow is 5. This accommodates concurrent taps while rejecting replays.

7.6 Rate limiting

The dev.atlocally.tap endpoint SHOULD enforce rate limits to mitigate abuse. Recommended limits:

These limits are not enforced at the protocol level. Platform operators configure them based on deployment context.

8. Presence

The Core Service does not store presence records and does not retain visitor identity beyond the duration of a tap request. A successful tap yields a proof JWT (Section 3), returned to the caller. Records describing the tap, if any, are written to the visitor's PDS by the app.

Apps that need aggregate views ("recent check-ins at this location") maintain their own indexes of the dev.atlocally.presence records they observe — either from records they themselves brokered into the visitor's PDS, or by consuming the atproto firehose. The Core Service is not a shared aggregator.

8.1 Recommended lexicon

An app that publishes a presence record to the visitor's PDS SHOULD use the dev.atlocally.presence lexicon:

{
  "$type": "dev.atlocally.presence",
  "location": "at://did:web:platform.example.com/dev.atlocally.location.profile/park",
  "locationName": "Riverside Park",
  "visitDate": "2026-04-11",
  "proof": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InByZXNlbmNlLXByb29mLWtleSJ9..."
}
Field Description
location Location AT-URI (matches the proof's loc claim).
locationName Human-readable name at time of visit.
visitDate Date of visit in UTC (YYYY-MM-DD). Day-granularity default — see Appendix C.6.
proof The presence proof JWT returned by the Core Service. Makes the record self-verifying.

The record's rkey MUST equal the proof's jti claim. Because jti is unique per tap and putRecord on a PDS treats the same rkey as an update, this guarantees at most one presence record per tap on the visitor's PDS — a replay-writing attempt overwrites the existing record rather than creating a duplicate.

The app calls com.atproto.repo.putRecord against the visitor's PDS with rkey = proof.jti. Writing on behalf of the visitor requires an OAuth session; the App Platform (Section 13) brokers this for apps that don't manage OAuth themselves. Apps with their own OAuth client write directly.

The Core Service does not observe or require this record. An app that doesn't want to publish presence (e.g., a private check-in system) simply omits the write.

8.2 Record verification

A consumer reading a dev.atlocally.presence record from a visitor's PDS verifies it by:

  1. Decode the embedded proof JWT (no signature check yet).
  2. Resolve the platform DID from the JWT's iss claim, fetch JWKS or DID document (Section 5).
  3. Verify the ES256 signature using the public key matching the JWT's kid.
  4. Check claim consistency:
    • JWT's sub equals the repo's DID (the repo that holds the record).
    • JWT's loc equals the record's location field.
    • JWT's jti equals the record's rkey.
  5. If all match, the record is genuine — the Core Service issued a proof for this visitor at this location, and the record hasn't been fabricated or tampered with.

Consumers reading historical records verify via signature and claim consistency alone; the proof carries no expiry. Apps that need freshness (e.g., a write window) apply their own policy against tapped_at.

9. Location identity

A location is identified by the AT-URI of its profile record on the platform's repo:

at://{platform-did}/dev.atlocally.location.profile/{location-id}

This matches how atproto identifies non-actor entities (posts, feeds, lists). The platform is the only DID in the system; locations are records within its repo.

9.1 Location types

Each location has a type that determines its tap source:

Type Tap source Counter Created when
qr Platform-generated QR codes Software (in-memory, recorded on tap) dev.atlocally.createLocation (default)
nfc Physical NTAG 424 DNA tag Hardware (tag manages counter) dev.atlocally.setLocationTagUid

Locations default to qr at creation. When a physical NFC tag is provisioned and its UID is registered via dev.atlocally.setLocationTagUid, the location type changes to nfc. The QR display endpoint (GET /qr/{id}) only serves QR-type locations.

9.2 Location profile records

Location metadata is stored as a record on the platform's repo. The record's AT-URI is the location's identifier.

{
  "$type": "dev.atlocally.location.profile",
  "name": "Riverside Park",
  "description": "Community park by the river"
}

One record per location; rkey is the location's short ID (e.g. park). Queryable via standard atproto XRPC:

GET /xrpc/com.atproto.repo.getRecord
  ?repo={platform-did}
  &collection=dev.atlocally.location.profile
  &rkey={location-id}

Or via the dev.atlocally.getLocation method with the short ID.

9.3 Parsing the loc claim

An AT-URI has the form at://{did}/{collection}/{rkey}. Consumers parse the proof's loc claim to extract:

An AT-URI with a collection other than dev.atlocally.location.profile is malformed and MUST be rejected.

9.4 Stability and migration

Location identifiers remain stable as long as the platform DID and the location's rkey don't change. Because the platform DID is a did:web, it is domain-dependent: renaming the platform's domain invalidates the AT-URI in every existing proof (the proofs remain cryptographically valid, but their loc claims point at an inaccessible platform).

Operators planning domain migrations SHOULD consider migrating to did:plc for the platform identity first, as did:plc is domain-independent. See Appendix E.

10. Core Service API

The Core Service is an XRPC service. Application-specific methods use the dev.atlocally.* lexicon namespace. Records use the standard com.atproto.repo.* surface, scoped to the platform's own repo. Well-known paths (DID documents, JWKS) and operational paths (tag URLs, QR displays) stay plain HTTP per atproto convention. Lexicon definitions are listed in Appendix F.

10.1 Public methods (no auth)

Queries (GET /xrpc/<method>):

Method Description
dev.atlocally.listLocations List locations. Optional: tagUid, owner
dev.atlocally.getLocation Get a single location
dev.atlocally.listServices List services. Optional: location, owner
com.atproto.repo.getRecord Get a record on the platform repo (location profiles)
com.atproto.repo.listRecords List records on the platform repo
com.atproto.repo.describeRepo Describe the platform repo

Procedures (POST /xrpc/<method>):

Method Description
dev.atlocally.verifyProof Verify a proof JWT

10.2 Authenticated methods

All require an AT Protocol service auth token (Section 7.2) in the Authorization: Bearer header. The Core Service verifies the token by resolving the caller's DID document (see Section 7.3). The token's lxm claim MUST equal the invoked method's NSID; tokens with a missing or mismatched lxm are rejected.

Method Expected lxm Description
dev.atlocally.tap dev.atlocally.tap Verify a tap, issue a proof
dev.atlocally.createLocation dev.atlocally.createLocation Create a location (ID generated server-side)
dev.atlocally.setLocationTagUid dev.atlocally.setLocationTagUid Set tag UID, change type to NFC, sync counter HWM
dev.atlocally.registerService dev.atlocally.registerService Register or update a service (Section 11)
dev.atlocally.deleteService dev.atlocally.deleteService Remove a service (owner only)

The Core Service does not expose com.atproto.repo.putRecord or deleteRecord. Location profiles are managed via dev.atlocally.createLocation and related admin methods. Visitor records live on the visitor's PDS and are managed through that PDS's own atproto API.

10.3 Error responses

XRPC methods return errors using the standard atproto shape: an HTTP status code plus a JSON body.

{
  "error": "ErrorName",
  "message": "Optional human-readable detail."
}

The error field is a short CamelCase identifier. It MUST equal one of the names listed in the method's lexicon errors array, or one of the cross-cutting errors below. Clients SHOULD dispatch on error; the message field is free-form and intended for logs and developer tooling, not for parsing.

Cross-cutting errors, available to any authenticated method:

HTTP error Cause
400 InvalidRequest Request shape does not match the method's input schema.
401 AuthRequired No Authorization header, or header not in Bearer <token> form.
401 InvalidToken Token signature invalid, aud mismatch, or lxm missing or mismatched.
401 TokenExpired Service auth token's exp has passed.
403 NotAuthorized Token is valid but the verified DID is not permitted for this operation (e.g., trying to delete a service owned by another DID).
429 RateLimitExceeded Request exceeded a rate limit. Response SHOULD include a Retry-After header.
500 InternalServerError Unexpected server failure.

Method-specific errors are declared in each lexicon's errors array (see Appendix F). For example, dev.atlocally.tap defines InvalidCmac, UnknownTag, StaleCounter, and Replay alongside the cross-cutting set.

Non-XRPC endpoints (Section 10.6) use plain HTTP status codes. Well-known JSON paths return a plain HTTP error with no body; /t/{data} and /qr/{id} return HTML error pages appropriate to their contexts.

10.4 Cross-origin access

The Core Service is a public protocol surface designed for cross-origin browser access. All XRPC methods and well-known paths respond with permissive CORS headers:

The Core Service does not support credentialed requests. Access-Control-Allow-Credentials is never set; browsers invoking fetch with credentials: "include" will fail preflight.

Authentication is always via the Authorization: Bearer <service-auth-token> header, which browser clients attach explicitly per request. There are no session cookies on the Core Service to protect. The App Platform (Part 2), which does use cookies for OAuth sessions, maintains its own origin allowlist (Section 13.2) and is not governed by this section.

10.5 Caching

Cache behavior by endpoint category.

Key-discovery endpoints. The Core Service sends Cache-Control: public, max-age=3600:

Path Rationale
/.well-known/did.json DID document is stable between key rotations.
/.well-known/jwks.json See Section 5.2.
/.well-known/did-log.json Append-only history; entries only change on rotation.

Verifiers caching these SHOULD observe the max-age and MUST NOT refetch more than once per minute under normal operation.

Authenticated procedures (dev.atlocally.tap, createLocation, setLocationTagUid, registerService, deleteService) send Cache-Control: no-store. These endpoints mutate state or require fresh authentication per call.

Unauthenticated procedures (dev.atlocally.verifyProof) send Cache-Control: no-store. POST responses are not cacheable by intermediaries in the general case, and verifying a proof is cheap enough that caching offers no meaningful benefit.

Public query endpoints (dev.atlocally.listLocations, getLocation, listServices, and the com.atproto.repo.* surface) send Cache-Control: no-cache by default. Operators running a CDN or caching proxy MAY configure a short positive max-age for these; no value is normative.

Non-XRPC operational endpoints (/t/{data}, /qr/{id}, /qr/{id}/events) send Cache-Control: no-store. Tap URLs are single-use, QR pages render a fresh code per load, and SSE streams are live.

10.6 Non-XRPC endpoints

Plain HTTP paths for things where XRPC is not the right shape:

Method Path Purpose
GET /.well-known/did.json Platform DID document (W3C did:web resolution)
GET /.well-known/jwks.json Proof signing keys (JWT ecosystem standard)
GET /.well-known/did-log.json Platform key history (Section 5.3)
GET /lexicons/{nsid}.json Lexicon JSON for any method or record under dev.atlocally.* (Appendix F)
GET /t/{data} Compact tap URL, for NFC tag NDEF records (redirects to tap flow)
GET /qr/{id} QR display page (HTML, for QR-type locations)
GET /qr/{id}/events SSE stream — notifies displays when a QR has been scanned

10.7 What the Core Service does NOT provide

These are the App Platform's domain (Part 2) or the application's own responsibility.

11. Service registration

Services register with the Core Service to be discoverable by tap routers and by other clients. Registration is optional — proof issuance does not require registration. A service that never registers can still verify taps and receive proofs.

11.1 Model

Any developer with an AT Protocol DID can register services. The model is open by default — there is no location-owner gatekeeping. Service registration is authenticated via AT Protocol service auth (the same mechanism used for location creation and tap verification).

11.2 Registering

POST /xrpc/dev.atlocally.registerService
Authorization: Bearer <service-auth-token>
Content-Type: application/json

{
  "id": "guestbook",
  "name": "Guestbook",
  "callbackUrl": "https://guestbook.example.com",
  "description": "Leave a message at this location.",
  "scope": "all"
}
Field Required Description
id Yes Unique service identifier.
name Yes Human-readable name.
callbackUrl Yes Where the App Platform redirects users after a tap (Section 13).
description No One-line description.
scope No "all" (every location) or a JSON array of location IDs. Defaults to "all".

Registration is idempotent — calling it again with the same id updates the existing entry. Only the original owner (the DID that first registered the service) can update it.

11.3 Scope

Services can target all locations or a specific set:

11.4 Querying

GET /xrpc/dev.atlocally.listServices                        → all registered services
GET /xrpc/dev.atlocally.listServices?location=<id>          → services available at a specific location (filtered by scope)
GET /xrpc/dev.atlocally.listServices?owner=did:plc:X        → services owned by a specific DID

No authentication required. Tap routers and apps use this method to discover which services apply at a given location — the Core Service is the source of truth for service discovery.

Results are returned in a stable, implementation-defined order. The response includes a cursor when more entries are available. Default limit is 50; maximum 100.

11.5 Removing

POST /xrpc/dev.atlocally.deleteService
Authorization: Bearer <service-auth-token>
Content-Type: application/json

{ "id": "guestbook" }

Only the owner (the DID that registered the service) can remove it.

11.6 Rate limiting

The dev.atlocally.registerService endpoint SHOULD enforce rate limits. Recommended: no more than 10 registrations per hour per DID.


Part 2: App Platform

The App Platform is an optional convenience layer that sits between the Core Service and applications. It handles the things most simple apps need but shouldn't have to build themselves: OAuth, tap routing, Bluesky integration, and app registration.

You don't need the App Platform. If your app handles its own OAuth and reads NFC tags directly (or accepts proofs from users), you can talk to the Core Service and skip everything in Part 2. The App Platform exists to make the common case easy.

12. What the App Platform does

The App Platform wraps the Core Service and adds:

Capability What it does Why apps want it
AT Protocol OAuth Handles the full OAuth flow with Bluesky PDS servers Apps don't need their own OAuth client
Service auth brokering Obtains service auth tokens from the visitor's PDS and forwards them to the Core Service Apps don't need to implement DID resolution or service auth
Tap routing Redirects NFC/QR taps to the right app based on registered services Users scan once and land in the right place
Bluesky posting Posts to Bluesky on behalf of authenticated users Apps don't need PDS write access
Session management Tracks active OAuth sessions Apps can check if a user is logged in
App registry Tracks which apps have OAuth/API key access Enables CORS allowlisting and per-app credentials

Note: service discovery is a Core Service concern (Section 11). The App Platform queries the Core Service's dev.atlocally.listServices for tap routing.

13. Registration

There are two separate registrations, serving different purposes:

13.1 Service registration (Core Service)

Service registration with the Core Service (Section 11) makes the app discoverable by tap routers and other clients. This uses AT Protocol service auth — no admin secret or shared credentials.

13.2 App Platform registration (optional)

Apps that use the App Platform's OAuth, session management, or Bluesky posting register separately with the App Platform. This provides an API key and adds the app's callback URL to the CORS allowlist.

POST /admin/apps
Authorization: Bearer <admin-secret>
Content-Type: application/json

{
  "id": "guestbook",
  "name": "Guestbook",
  "description": "Leave a message at this location for other visitors to read.",
  "callbackUrl": "https://guestbook.example.com"
}
Field Required Description
id Yes Unique app identifier. Lowercase, alphanumeric.
name Yes Human-readable name shown to users.
callbackUrl Yes Whitelisted for OAuth redirects and CORS.
description No One-line description.

The App Platform returns a per-app API key on registration. The admin secret is an operator-only credential — apps use their API key for ongoing calls.

13.3 Listing apps

GET /apps                    → all registered apps
GET /apps?location=<id>      → apps registered with the App Platform for a location

No authentication required. Returns id, name, description, and callbackUrl for each app.

14. Tap routing

When a user scans an NFC tag or QR code, the URL points to the platform's domain. The App Platform intercepts the tap URL and decides where to send the user, querying the Core Service's services API.

14.1 Flow

1. User scans: GET /t/{version}-{payload}

2. App Platform parses the tap URL to extract the tag UID.

3. App Platform looks up the location from the tag UID (via Core Service).

4. App Platform queries Core Service for services at that location
   (`GET /xrpc/dev.atlocally.listServices?location=<id>`).

5. Routing:
   - One service registered → redirect to that service's callbackUrl with tap params
   - Multiple services → present a chooser to the visitor
   - No services → redirect to the platform's default page

The tap parameters are passed to the app as a URL fragment:

https://guestbook.example.com#tap=1-{uid}{ctr}{cmac}

The app then obtains a service auth token (Section 15.2) and submits those parameters to POST /xrpc/dev.atlocally.tap on the Core Service to get a proof.

14.2 Apps that don't need tap routing

An app can bypass tap routing entirely by reading NFC tags directly:

In all cases, the app extracts uid, ctr, and cmac from the tag's URL, obtains a service auth token, and calls POST /xrpc/dev.atlocally.tap on the Core Service with the token in the Authorization header.

15. App Platform APIs

15.1 OAuth

GET /login?handle=<handle>&redirect=<app-callback-url>

Starts the AT Protocol OAuth flow. After authentication, redirects to the app's callback URL with #auth=<did> appended. The redirect URL MUST match a registered app's callbackUrl.

GET /oauth/callback

OAuth callback handler. Not called by apps directly.

GET /client-metadata.json

OAuth client metadata for PDS discovery.

15.2 Service auth brokering

The Core Service requires a service auth token for every authenticated request (Section 7.2). Obtaining one requires an OAuth session with the visitor's PDS. The App Platform bridges this gap: apps that use the App Platform's OAuth don't need to implement service auth themselves.

POST /session/service-token
Content-Type: application/json

{ "did": "did:plc:abc123", "lxm": "dev.atlocally.tap" }

→ { "token": "<service-auth-jwt>" }

The App Platform uses its stored OAuth session to call com.atproto.server.getServiceAuth on the visitor's PDS, with aud set to the Core Service's DID and a 60-second expiry. The returned token can be passed directly to the Core Service's POST /xrpc/dev.atlocally.tap in the Authorization: Bearer header.

Field Required Description
did Yes The visitor's DID. Must have an active OAuth session on the App Platform.
lxm No The lexicon method to bind the token to. Defaults to dev.atlocally.tap.

This endpoint requires a session cookie set during the OAuth callback. The cookie is an HMAC-signed token containing the visitor's DID, verified against the requested DID to prevent callers from minting tokens for other users. The cookie is HttpOnly, Secure (HTTPS), SameSite=None (HTTPS) or SameSite=Lax (HTTP), with a 7-day expiry. SameSite=None is required for cross-origin app frontends to send the cookie on fetch requests.

15.3 Session check

POST /session/check
Content-Type: application/json

{ "did": "did:plc:abc123", "proof": "<presence-proof-jwt>" }

→ 200 { "active": true, "did": "did:plc:abc123" }
→ 401 { "active": false, "did": "did:plc:abc123" }

Apps call this to verify a user has an active OAuth session before allowing writes. Requires a valid presence proof matching the requested DID.

Service discovery is a Core Service concern. Tap routing queries dev.atlocally.listServices?location={id} against the Core Service to find what apps are available at a location; the App Platform does not maintain its own service registry.

15.4 PDS record creation

POST /pds/create-record
Content-Type: application/json

{
  "did": "did:plc:abc123",
  "collection": "app.atlocally.guestbook.post",
  "record": { ... },
  "proof": "<presence-proof-jwt>"
}

→ 201 { "uri": "at://did:plc:abc123/app.atlocally.guestbook.post/rkey", "cid": "..." }

Creates a record on the visitor's PDS using the App Platform's stored OAuth session. This enables apps to write visitor-signed AT Protocol records without implementing their own OAuth client.

Requires a valid presence proof matching the requested DID. The App Platform verifies the proof against the Core Service's JWKS, then restores the visitor's OAuth session and calls com.atproto.repo.createRecord on the visitor's PDS. Rate limited to 10 writes per minute per DID.

Use cases:

16. Integration patterns

16.1 Simple app (uses App Platform)

Best for: hackathon projects, community tools, single-developer apps.

┌──────────┐     ┌──────────────┐     ┌───────────────┐
│  Visitor  │────▶│ App Platform │────▶│ Core Service │
│  (phone)  │◀────│  + your app  │◀────│               │
└──────────┘     └──────────────┘     └───────────────┘

Your app:

  1. Registers a service with the Core Service for discovery (Section 11).
  2. Optionally registers with the App Platform for OAuth/API keys (Section 13.2).
  3. Receives tap redirects with parameters in the URL fragment.
  4. Obtains a service auth token via the App Platform (Section 15.2) and calls POST /xrpc/dev.atlocally.tap on the Core Service to get a proof.
  5. Verifies proofs via JWKS (or the dev.atlocally.verifyProof convenience method).
  6. Calls POST /session/check before allowing writes.
  7. Calls POST /pds/create-record to write a dev.atlocally.presence record (and/or app-specific records) to the visitor's PDS.

What you don't build: OAuth, service auth, DID resolution, NFC handling, Bluesky API integration.

16.2 Independent app (Core Service only)

Best for: businesses, apps with their own auth, apps that need full control.

┌──────────┐     ┌──────────┐     ┌───────────────┐
│  Visitor  │────▶│ Your App │────▶│ Core Service │
│  (phone)  │◀────│          │◀────│               │
└──────────┘     └──────────┘     └───────────────┘

Your app:

  1. Reads NFC tags directly (Web NFC, native app) or hosts its own QR codes.
  2. Authenticates the visitor via AT Protocol OAuth (your own client).
  3. Obtains a service auth token from the visitor's PDS (com.atproto.server.getServiceAuth with aud set to the Core Service's DID).
  4. Calls POST /xrpc/dev.atlocally.tap on the Core Service with the tap parameters and the service auth token.
  5. Verifies proofs via JWKS.
  6. Writes dev.atlocally.presence (and/or app-specific records) to the visitor's PDS via com.atproto.repo.putRecord, using its own OAuth session.

What you build yourself: OAuth client, service auth token acquisition, NFC reading or QR hosting, record creation on the visitor's PDS. What you get from the Core Service: tap verification, cryptographic identity verification, proof issuance, JWKS.

Independent apps may optionally register their services with the Core Service (Section 11) for discovery purposes, but this is not required for proof issuance. An app that never registers can still verify taps and receive proofs.

16.3 Self-hosted platform

For operators who want full control over the infrastructure:

Multiple Core Services can coexist. A verifier only needs the issuer's JWKS URL (derived from the iss claim) to verify any proof, regardless of which platform issued it.


Appendices

Appendix A: Tap sources

The Core Service accepts taps from two sources: NFC tags and QR displays. Both produce the same SUN URL format. The proof issuance flow (Section 7) is the same regardless of source.

A.1 SUN URL format

The tap URL uses a compact, versioned format:

https://platform.com/t/{version}-{payload}

The version prefix determines how to parse the payload. The version and payload are separated by a - (hyphen), which cannot appear in the hex payload, making the split unambiguous.

Version table

Version Tag type Payload format Total payload Description
1 NTAG 424 DNA uid(14) + ctr(6) + cmac(16) 36 hex chars Plain SDM with UID + counter + CMAC mirroring

Future tag types or encoding changes use new version numbers. The server checks the version and parses accordingly. Existing tags and QR codes continue to work — the version is fixed at generation time.

Example

https://platform.com/t/1-04659B824F239000004C94F7221B6C1253B1

Parsed as version 1:

Field Hex offset Size Value Description
uid 0–13 7 bytes (14 hex) 04659B824F2390 Tag identifier
ctr 14–19 3 bytes (6 hex) 00004C SDM read counter
cmac 20–35 8 bytes (16 hex) 94F7221B6C1253B1 Truncated SDMMAC

All fields are uppercase hex. The counter is displayed MSB-first in the URL. The UID and counter sizes are defined by the NTAG 424 DNA hardware. The CMAC size is defined by the truncation in the SDM MAC calculation (Section 9.1.3 of the NT4H2421Gx datasheet).

A.2 NFC tags (NTAG 424 DNA)

The NTAG 424 DNA chip computes the CMAC in a hardware secure element. The AES key is written during provisioning (Appendix B) and cannot be read back.

Tag identification: Each tag has a factory-assigned 7-byte UID. No two tags share a UID.

Counter: Hardware-monotonic, fused into the chip. Cannot be reset or decremented.

Proximity: NFC communication requires ~4cm between the phone and tag.

Cloning resistance: The AES key resides in tamper-resistant silicon. Software-based cloning is not possible.

A.3 QR displays

A QR display is a screen at a location that shows a QR code containing a SUN URL. The platform generates the URL server-side using the same AES key and counter logic, but in software.

Counter: Software-managed on the platform in memory, seeded from the database high-water mark on startup. The counter advances on each QR generation but is NOT recorded in the database — only verified taps are recorded, matching NFC behavior.

Refresh: QR codes are generated on demand (one per page load). Each generated QR is single-use — once scanned and verified, the display is notified via Server-Sent Events to reload with a fresh QR code. No periodic auto-refresh.

Multiple displays: A location can have multiple QR display screens simultaneously. Each page load generates a QR with a unique counter, allowing multiple people to verify at the same time.

Proximity: Camera range (meters). Weaker than NFC.

A.4 Security comparison

Property NFC (NTAG 424 DNA) QR display
Key storage Hardware secure element Platform server
Proximity required ~4cm Camera range (meters)
Cloning resistance Very high N/A (server-generated)
Counter integrity Hardware-monotonic Software-managed
Offline operation No power needed Requires power + network
Deployment cost $3–5 per tag Requires display device

Appendix B: Tag provisioning

B.1 Provisioning flow

  1. Create a location on the platform. The platform generates an AES-128 key and a tag UID placeholder.
  2. Read the tag's UID from the physical tag.
  3. Write the AES key to the tag's key slot.
  4. Set the NDEF URL template: https://platform.com/tap?uid={UID}&ctr={CTR}&cmac={CMAC}.
  5. Lock the AES key slot to prevent the key from being overwritten.
  6. Lock the NDEF file's write access to require the master key (or set to 0F for permanent read-only).
  7. Change the master key to a value known only to the operator.
  8. Register the tag's UID on the platform.

Step 6 prevents unauthorized rewriting of the NDEF URL template. Without it, anyone with physical access and an NFC writing app could redirect the tag's URL to a different server. The AES key and CMAC would still be valid (the key is in the secure element), but taps would be sent to the wrong destination.

Operators choosing between master-key-locked and permanent read-only should consider the tradeoff: permanent locking prevents URL template updates if the platform domain changes, while master-key locking allows updates but requires physical access to each tag.

B.2 URL template

The NDEF URL template is separate from the AES key. If write access is locked to the master key (not permanent read-only), the operator can rewrite the URL template without re-provisioning the AES key. This is useful when changing domains — the tag's cryptographic identity (UID + AES key) is unaffected.

In deployments with an App Platform, the URL template SHOULD point to the App Platform's domain so that its GET /t/{data} tap-routing handler directs users to the appropriate app. Independent apps that read NFC directly do not depend on the URL template domain.

B.3 Tag lifecycle

Appendix C: Security considerations

C.1 Trust model

The system has three credentials in sequence: the SUN URL (from a tag or QR display), the service auth token (from the PDS), and the proof JWT (from the platform).

What the SUN URL attests depends on which tap mode produced it:

The service auth token proves "this DID is authenticated by their PDS." The proof JWT combines the relevant tap evidence with the verified identity: "this authenticated DID submitted a valid tap at this location at this time."

The SUN URL and proof JWT are bearer credentials — whoever holds them can use them. The service auth token is audience-bound (to the Core Service's DID) and short-lived (recommended 60 seconds), limiting its reuse.

C.2 Properties

C.3 Non-properties

C.4 Bearer credential sharing

Sharing can occur at two points:

  1. SUN URL sharing: A visitor forwards the URL. The recipient submits it with their own DID.
  2. Proof sharing: A visitor forwards the JWT. The sub still identifies the original visitor.

The jti-as-rkey binding (Section 8.1) blocks one class of replay: a single proof cannot produce multiple presence records on the visitor's PDS — duplicate writes collide on the rkey and the PDS keeps only the most recent. A consumer listing dev.atlocally.presence records sees at most one per tap.

Further mitigations are application-layer concerns:

C.5 Revocation

Individual proof revocation is not supported. Proofs do not expire. Applications requiring revocation (e.g., to invalidate proofs from a compromised tap key) MUST implement their own revocation lists. Key rotation (Section 4.3) invalidates only future proofs signed by the retired key; previously-issued proofs remain verifiable via the DID log.

C.6 Visitor data

The Core Service records no visitor-identifying data beyond the duration of a tap request. Presence records — if any — live on the visitor's PDS (Section 8); the Core Service neither stores them nor exposes any aggregation over taps.

Consequences:

The visitDate day-granularity default in dev.atlocally.presence is a privacy default for published records. The full-precision tapped_at lives in the proof, which is ephemeral and held only by the caller. Apps that legitimately need higher precision (e.g., event check-ins) MAY include a tappedAt field, subject to the visitor's PDS privacy model.

Appendix D: AT Protocol conformance

The Core Service hosts a single repo under its own platform DID. That repo contains location profile records (keyed by location ID as the rkey) and is queryable via com.atproto.repo.listRecords, getRecord, and describeRepo for client compatibility. The platform does not expose XRPC repos for any other DID: locations are records within the platform repo, not separate actors, and visitor records live on the visitor's own PDS.

The platform's repo is a subset of a full atproto repo:

The dev.atlocally namespace (platform protocol) and app.atlocally namespace (apps) follow AT Protocol reverse-DNS convention, derived from atlocally.dev and atlocally.app respectively.

Appendix E: Future considerations

E.1 Tag-signed proofs

Current proofs are platform-signed. A stronger model would have the NFC tag itself sign the proof. Current NFC hardware does not support arbitrary signing — only AES-CMAC over a fixed message format. Future chips with asymmetric signing could enable fully decentralized presence proofs.

E.2 Per-app credentials

The App Platform issues per-app API keys on registration. The admin secret is an operator-only credential used for initial app registration. Core Service registration uses AT Protocol service auth — no shared secrets. A natural evolution is scoped permissions per API key (e.g., an app that can check sessions but not post to Bluesky).

Appendix F: Lexicons

The Core Service defines the following lexicons.

Records:

NSID Holder Purpose
dev.atlocally.location.profile Platform repo Location metadata (Section 9.2)
dev.atlocally.presence Visitor PDS Recommended lexicon for presence records on the visitor's repo (Section 8.1)

XRPC methods (all under the dev.atlocally.* namespace):

NSID Type Auth Section
dev.atlocally.tap procedure service auth 7.3
dev.atlocally.verifyProof procedure none 6.2
dev.atlocally.listLocations query none 9
dev.atlocally.getLocation query none 9
dev.atlocally.createLocation procedure service auth 9
dev.atlocally.setLocationTagUid procedure service auth 9
dev.atlocally.listServices query none 11
dev.atlocally.registerService procedure service auth 11
dev.atlocally.deleteService procedure service auth 11

Full JSON schemas live in the implementation repo under /lexicons/ and are also served by the Core Service at /lexicons/{nsid}.json (e.g., /lexicons/dev.atlocally.tap.json). The served files are the authoritative source for request/response shapes. This appendix is a catalogue only.

The dev.atlocally namespace is derived from the atlocally.dev domain per AT Protocol reverse-DNS convention. Apps SHOULD use app.atlocally.* for app-specific records written to visitor PDSes (e.g., app.atlocally.guestbook.post).