Solid gives you a pod — a personal data server where you control who sees what. Your identity is a WebID: a URL like https://alice.example/profile/card#me that resolves to a profile document.
Nostr is a decentralised social protocol. Over a million people use it. Your identity is a keypair: a private key that signs messages, and a public key that anyone can verify. No server needed for identity — the cryptography is the identity.
Both protocols believe in the same thing: you should own your identity and your data. They just started from different places. Solid started from the web and linked data. Nostr started from cryptography and relay networks.
This article connects them. Your Nostr key can authenticate you to a Solid pod. No signup, no password, no email. If you have a Nostr key, you already have a login.
A Nostr key is an secp256k1 keypair — the same elliptic curve Bitcoin uses. It consists of:
| What | Format | Example |
|---|---|---|
| Private key | 64-char hex | 3f7a...c9d2 |
| Public key | 64-char hex | 5e76...45af |
The private key signs things. The public key verifies signatures. You never share your private key. Your public key is your identity.
If you don't have a Nostr key yet, generate one:
npx noskey
Save the output. The hex public key (64 characters) is what we'll use.
A DID (Decentralised Identifier) is a W3C standard for identities that don't depend on any central authority. The did:nostr method maps a Nostr public key to a DID:
did:nostr:5e7617ea570cade67392eeec16f6666ec942b9215e0276939bf8f8ea869145af
That's it. No resolver, no registry, no blockchain lookup. The public key is the identifier. Anyone who knows the key can verify signatures from it.
In the Solid world, this DID works just like a WebID. You can put it in an .acl file as an acl:agent, and the server will recognise it when you authenticate.
HTTP normally uses tokens or cookies for authentication. NIP-98 (also specified as HTTP Schnorr Auth in the W3C Nostr Community Group) does it with cryptographic signatures instead.
To authenticate a request, you sign a Nostr event that says:
| Field | Value | Purpose |
|---|---|---|
kind | 27235 | NIP-98 HTTP Auth event |
created_at | Unix timestamp | Must be within ±60 seconds of now |
Tag u | The full URL | Binds the signature to this specific URL |
Tag method | GET, PUT, etc. | Binds the signature to this HTTP method |
pubkey | Your hex public key | Who is authenticating |
sig | Schnorr signature | Proves you hold the private key |
The signed event is base64-encoded and sent in the Authorization header:
Authorization: Nostr <base64-encoded-event>
The server decodes the event, checks the timestamp, verifies the URL and method match the request, validates the Schnorr signature, and — if everything checks out — identifies you as did:nostr:<your-pubkey>.
No secrets are transmitted. Unlike Bearer tokens, the signature proves you hold the private key without ever sending it. Each signature is bound to a specific URL and method, so a captured signature can't be replayed against a different endpoint.
No account needed. The server doesn't need to know about you in advance. Any valid Schnorr signature from any Nostr key is accepted. Your identity is derived from the key, not from a database.
If you followed Part 1, you have JSS running on localhost:4443 with Alice's pod. Let's authenticate with a Nostr key instead of a Bearer token.
First, create a small script that generates a NIP-98 auth header. Save this as nip98.mjs:
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
import { bytesToHex } from '@noble/hashes/utils';
// Use your own key, or generate a new one
const sk = generateSecretKey();
const pk = getPublicKey(sk);
const url = process.argv[2] || 'http://localhost:4443/';
const method = process.argv[3] || 'GET';
const event = finalizeEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [['u', url], ['method', method]],
content: ''
}, sk);
const token = Buffer.from(JSON.stringify(event)).toString('base64');
console.log(`pubkey: ${pk}`);
console.log(`did:nostr:${pk}`);
console.log(`Authorization: Nostr ${token}`);
Install the dependency and run it:
npm install nostr-tools node nip98.mjs http://localhost:4443/alice/public/note.json GET
This prints your public key and the Authorization header. Now use it:
curl -H "Authorization: Nostr <paste-the-token>" \ http://localhost:4443/alice/public/note.json
That reads Alice's public note — which was already public, so this just proves the auth header is accepted. The interesting part is using it for private resources.
Remember the ACL from Part 1? We granted Bob access by his WebID. Now let's grant access by a Nostr public key instead.
Say your Nostr hex pubkey is 5e7617ea570cade67392eeec16f6666ec942b9215e0276939bf8f8ea869145af. Create an ACL that lets that key read Alice's secret note:
curl -X PUT http://localhost:4443/alice/private/secret.json.acl \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/ld+json" \
-d '[
{
"@context": {"acl": "http://www.w3.org/ns/auth/acl#"},
"@id": "#owner",
"@type": "acl:Authorization",
"acl:agent": {"@id": "http://localhost:4443/alice/profile/card#me"},
"acl:accessTo": {"@id": "http://localhost:4443/alice/private/secret.json"},
"acl:mode": [{"@id": "acl:Read"}, {"@id": "acl:Write"}, {"@id": "acl:Control"}]
},
{
"@context": {"acl": "http://www.w3.org/ns/auth/acl#"},
"@id": "#nostr-user",
"@type": "acl:Authorization",
"acl:agent": {"@id": "did:nostr:5e7617ea570cade67392eeec16f6666ec942b9215e0276939bf8f8ea869145af"},
"acl:accessTo": {"@id": "http://localhost:4443/alice/private/secret.json"},
"acl:mode": [{"@id": "acl:Read"}]
}
]'
Now test it:
# Generate NIP-98 header for the private resource node nip98.mjs http://localhost:4443/alice/private/secret.json GET # Use it curl -H "Authorization: Nostr <paste-the-token>" \ http://localhost:4443/alice/private/secret.json
If the pubkey in your NIP-98 event matches the did:nostr in the ACL, you get the secret note. If it doesn't match, you get 403.
A Nostr key — the same key you use to post on Nostr, sign zaps, and log into apps — just authenticated you to a Solid pod and accessed a private resource. The same ACL system handles WebIDs, Bearer tokens, and Nostr keys. No separate auth system. No schism. One access control layer, multiple identities.
| Concept | Solid | Nostr | Bridge |
|---|---|---|---|
| Identity | WebID (URL) | Public key (hex) | did:nostr |
| Auth method | Bearer token / Solid-OIDC | Schnorr signature | NIP-98 / HTTP Schnorr Auth |
| Data storage | Pod (your server) | Relays (someone else's server) | Use both |
| Access control | .acl files (WAC) | Relay-level (coarse) | WAC with did:nostr agents |
| Users | Thousands | 1M+ keys | Any Nostr key works in a pod ACL |
The bridge is one line in an ACL file. No protocol changes. No new servers. No migration. A Solid pod operator adds a did:nostr to their ACL, and a million existing Nostr users can authenticate.
This isn't ad-hoc. The bridge is built on three W3C-track specifications from the Nostr W3C Community Group:
| Spec | What It Does | Link |
|---|---|---|
| did:nostr | Maps Nostr pubkeys to W3C DIDs | nostrcg.github.io/did-nostr |
| HTTP Schnorr Auth | NIP-98 as a W3C spec for HTTP auth | nostrcg.github.io/http-schnorr-auth |
| Web Access Control | Fine-grained permissions for web resources | webacl.org |
JSS implements all three. The combination means: any Nostr key holder can be granted access to any resource on any Solid pod, using standard ACL files, authenticated by standard Schnorr signatures.
Part 3: HTTP 402 — The Missing Status Code. Your Nostr key isn't just a login — it's also a payment address. Deposit testnet sats, pass a membership gate. The web's twenty-nine-year-old status code finally works.