JWT Authentication
JWT flow in LaWallet NWC: NIP-98 session exchange, server-side helpers, and scoped QR device tokens for the card apps.
This document explains the JWT flow that currently exists in lawallet-nwc.
Overview
The current auth flow works like this:
- Server-side JWT library (
lib/jwt.ts) - Core JWT functions - JWT route helpers (
lib/jwt-auth.ts) - Route protection utilities for JWT-only routes - Unified auth helpers (
lib/auth/unified-auth.ts) - Accept either NIP-98 or JWT - API endpoints (
/api/jwtand/api/jwt/protected) - Token creation, validation, and protected examples
There is no lib/jwt-client.ts helper in the current repo. Frontend callers should manage token storage themselves.
Setup
1. Environment Variables
Add the following to your .env file:
JWT_SECRET=your-super-secret-jwt-key-hereImportant: Use a strong, random secret key in production.
2. Dependencies
The required packages are already installed:
jsonwebtoken- JWT creation and verification@types/jsonwebtoken- TypeScript types
Server-Side Usage
Creating JWT Tokens
import { createJwtToken } from '@/lib/jwt'
const token = createJwtToken(
{
userId: 'user123',
role: 'admin',
permissions: ['read', 'write']
},
process.env.JWT_SECRET!,
{
expiresIn: '24h',
issuer: 'lawallet-nwc',
audience: 'lawallet-users'
}
)Verifying JWT Tokens
import { verifyJwtToken } from '@/lib/jwt'
try {
const result = verifyJwtToken(token, process.env.JWT_SECRET!)
console.log('User ID:', result.payload.userId)
console.log('Role:', result.payload.role)
} catch (error) {
console.error('Token verification failed:', error.message)
}Protecting API Routes
Option 1: JWT-only protection
import { authenticateJwt } from '@/lib/jwt-auth'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const auth = await authenticateJwt(request, {
requiredClaims: ['role']
})
return NextResponse.json({
userId: auth.payload.userId,
role: auth.payload.role
})
}Option 2: Higher-order JWT wrapper
import { withJwtAuth, getUserIdFromRequest } from '@/lib/jwt-auth'
import { NextResponse } from 'next/server'
import type { AuthenticatedRequest } from '@/lib/jwt-auth'
async function protectedHandler(request: AuthenticatedRequest) {
const userId = getUserIdFromRequest(request)
return NextResponse.json({
message: `Hello user ${userId}`,
timestamp: new Date().toISOString()
})
}
export const GET = withJwtAuth(protectedHandler, {
requiredClaims: ['role', 'permissions']
})Option 3: Accept NIP-98 or JWT
import { withAuth } from '@/lib/auth/unified-auth'
import { NextResponse } from 'next/server'
export const GET = withAuth(async (_request, auth) => {
return NextResponse.json({
pubkey: auth.pubkey,
role: auth.role,
method: auth.method
})
})Working with Authenticated JWT Requests
import {
getUserIdFromRequest,
getClaimFromRequest,
hasClaim
} from '@/lib/jwt-auth'
async function handler(request: AuthenticatedRequest) {
const userId = getUserIdFromRequest(request)
const role = getClaimFromRequest<string>(request, 'role')
const permissions = getClaimFromRequest<string[]>(request, 'permissions')
if (hasClaim(request, 'role', 'admin')) {
// Admin-only logic
}
if (hasClaim(request, 'permissions', 'write')) {
// Write permission logic
}
}Current API Contract
POST /api/jwt
Exchange a NIP-98 signed request for a JWT session token.
Request Body:
{
"expiresIn": "24h"
}The body is optional and defaults to 1h. expiresIn accepts an ms-style
duration (30m, 8h, 1d) or a plain number of seconds, and is capped at
24 hours — session tokens are stateless and unrevocable, so a leaked one
must not stay usable for weeks. An out-of-range or malformed value returns
400. Device tokens are minted through a different endpoint and keep their own,
operator-chosen lifetimes.
Headers:
Authorization: Nostr <base64-encoded-nip98-event>Response:
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": "24h",
"type": "Bearer"
}GET /api/jwt
Validate an existing JWT token.
Headers:
Authorization: Bearer <token>Response:
{
"valid": true,
"pubkey": "npub-or-hex-pubkey",
"role": "USER",
"permissions": ["read"],
"issuedAt": "2024-01-01T00:00:00.000Z",
"expiresAt": "2024-01-02T00:00:00.000Z"
}Token claims (payload)
The decoded JWT payload carries the actor's identity and authorization. Every token has the registered + identity claims below; device tokens (QR login) add a few more.
Registered claims (every token):
| Claim | Value |
|---|---|
iss | lawallet-nwc |
aud | lawallet-users |
iat | Issued-at, Unix seconds |
exp | Expiry, Unix seconds (iat + expiresIn) |
Identity & authorization (session JWT and device tokens):
| Claim | Type | Meaning |
|---|---|---|
userId | string | Actor's hex Nostr pubkey (doubles as the subject) |
pubkey | string | Actor's hex Nostr pubkey |
role | ADMIN | OPERATOR | VIEWER | USER | Resolved role |
permissions | string[] | Permissions for the role (device tokens mirror scopes here) |
Device-token-only claims (POST /api/auth/qr-jwt/generate):
| Claim | Type | Meaning |
|---|---|---|
kind | 'device' | Marks the token as a delegated device token |
sub | string | Target user's DB id (for auditing) |
scopes | string[] | Delegated permission subset — authoritative for permission-gated routes (see Scopes) |
apiUrl | string | Platform base URL the token is bound to, e.g. https://app.example.com — enforced on every request (see Instance scoping) |
Session JWTs (from POST /api/jwt) and NIP-98 requests carry no kind, sub, scopes, or
apiUrl.
How tokens expire
JWTs here are stateless — the server keeps no record of issued tokens. A token isn't expired by a background job or a database flag; it simply stops being accepted once the deadline baked into it passes.
- At mint time, the
expiresInoption becomes anexpclaim — a Unix timestamp (iat + duration) thatjsonwebtokenwrites into the payload and the signature covers. A holder can't push it later without theJWT_SECRET. - On every request,
verifyJwtToken(lib/jwt.ts) re-checks the token: it validates the signature and comparesexpagainst the current server clock. Oncenow >= exp, verification throws and the request is rejected with401. No timer, no cleanup — just a per-request check.
request → verify signature → now < exp ? → allow : 401Expiry is not revocation
Because validation is signature + exp only and nothing is stored server-side, there is no way to
invalidate one specific token early. To cut access before exp you either let the token lapse
on its own (so keep lifetimes short) or rotate JWT_SECRET, which invalidates every token at
once.
Session tokens refresh; device tokens don't
The admin dashboard layers a convenience on the same mechanism: it reads exp and, shortly before a
session JWT lapses, re-signs a NIP-98 event and calls POST /api/jwt for a fresh token so an active
session isn't logged out mid-use. The server side is unchanged — it still only checks exp.
Device tokens have no refresh: when one expires, the operator
generates a new QR.
Device Tokens (QR login)
Device tokens are stateless, scoped JWTs an admin mints for the third-party card apps
(card-installer, card-manager). The admin generates one from the dashboard, renders it
as a QR, and the card app scans it to authenticate — no shared password, no per-device account.
They follow the stateless expiry model above: validation is signature + exp
only, with no revocation. Lifetimes have a 1-minute floor but no maximum — prefer short
expirations, and to cut a device off early let the token lapse and mint a fresh one.
Generating a token (admin UI)
Settings → Device Tokens (also a sub-item under Settings in the admin sidebar):
- Pick the user the device will act as.
- Tick the permissions to grant — a subset of your own RBAC. The Card provisioning preset selects the card / design / NTAG / address scopes the card apps need.
- Choose an expiration (
1h·8h·24h·7d· custom). - Generate → the JWT is rendered as a QR with a copy-to-clipboard fallback.
POST /api/auth/qr-jwt/generate
Mint a device token. Admin only — authenticates via NIP-98 or the dashboard's Bearer JWT, and is rate-limited per admin.
Headers:
Authorization: Bearer <admin-jwt> # or: Nostr <base64-encoded-nip98-event>Request body:
{
"userId": "clx0abcd1234",
"permissions": ["cards:read", "cards:write", "ntags:write"],
"expiresIn": "8h"
}userId— DB id of the target user the token authenticates as.permissions— scopes to grant. Must be a subset of the calling admin's permissions; unknown strings return400, and granting a permission the caller lacks returns403.expiresIn— ams-style duration (8h,7d) or a plain number of seconds. Anything below the 1-minute floor is rejected; there is no upper bound.
Response:
{
"jwt": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": "8h",
"scopes": ["cards:read", "cards:write", "ntags:write"],
"apiUrl": "https://app.example.com",
"user": { "id": "clx0abcd1234", "pubkey": "<hex-pubkey>", "role": "OPERATOR" }
}apiUrl— the platform base URL this token is bound to: the instance'sendpointsetting, or the request host the mint came in on whenendpointis unset (e.g.http://localhost:55067in local dev). It is deliberately not the lightning-addressdomain— a token must point at the API the device will call, not the public address domain. It is also embedded as a claim inside the JWT, so the scanning app can decode the token to learn which API to call. See Instance scoping for how it is enforced.
The card app then sends the jwt as a standard bearer token on its requests:
Authorization: Bearer <jwt>Available permissions
permissions accepts any of the values below, defined in
lib/auth/permissions.ts.
You can only grant permissions your own role holds — see the full role → permission matrix in
Roles & Permissions.
| Permission | Allows |
|---|---|
cards:read | Read cards |
cards:write | Create, update, and pair cards |
card_designs:read | Read card designs |
card_designs:write | Create and update card designs |
ntags:read | Read NTAG424 keys |
ntags:write | Write NTAG424 keys |
addresses:read | Read Lightning addresses |
addresses:write | Create and update Lightning addresses |
settings:read | Read instance settings |
settings:write | Update instance settings |
users:read | Read users |
users:write | Create and update users |
users:manage_roles | Change a user's role |
activity:read | Read the activity log |
The Card provisioning preset in the admin UI selects cards:read, cards:write,
card_designs:read, card_designs:write, ntags:read, ntags:write, and addresses:read.
Scopes and how they are enforced
A device token carries an extra scopes claim alongside the usual identity claims. The token
authenticates as the target user (its pubkey + role), but scopes narrows what it can
do:
- On permission-gated routes (
authenticateWithPermission), the effective permission set is thescopesarray — it overrides the role→permission map. A token scoped tocards:readcannot write cards even if the target user's role normally could; conversely the admin can delegate a capability beyond the user's base role, becausescopesis a subset of the admin's RBAC, not the user's. - The check is fail-closed: a malformed
scopesclaim, or one containing only unknown strings, resolves to an empty set and denies all permission-gated access. - Role-gated routes (
authenticateWithRole) still use the target user's role. For examplePOST /api/cardsrequiresADMIN, so a device token only provisions cards when its target user is an admin. - Session JWTs (from
POST /api/jwt) and NIP-98 requests carry noscopesclaim and are unaffected — they resolve permissions from the full role→permission map as before.
Instance scoping (apiUrl)
A device token also carries an apiUrl claim — the base URL of the instance that minted it. It is
resolved from the endpoint setting, falling back to the request host when endpoint is
unset (so a local dev instance binds to its own http://localhost:<port>). It is not the
lightning-address domain: an instance served at http://localhost:55067 may advertise a public
address domain of lacrypta.ar for LUD-16, but the device must call the former. It serves two
purposes:
- Discovery — the card app decodes the token (the claim is readable without the secret) to learn which API base to call, so a single scanned QR is enough to point the device at the right instance.
- Binding — on every Bearer request,
authenticateJwt(lib/auth/unified-auth.ts) resolves the serving instance's URL and compares it to the token'sapiUrl. A device token whoseapiUrlis missing or doesn't match is rejected with401— a token minted for one instance can't be replayed against another. The comparison is normalized (trimmed, trailing slash dropped, case-insensitive on the host).
Only device tokens carry apiUrl; session JWTs (no kind: 'device' claim) and NIP-98 requests skip
this check entirely.
Changing the instance's endpoint setting (or, when it's unset, the host the
instance is served on) invalidates every previously issued device token,
since their apiUrl no longer matches — re-mint afterwards.
Device tokens cannot be revoked before exp. Grant only the scopes the device
needs, prefer the shortest workable expiration, and regenerate to change
access — previously issued tokens stay valid until they expire.
Frontend Note
If you are building a frontend against this repo today, store the JWT however your app prefers and send it as a standard bearer token:
Authorization: Bearer <token>There is no first-party browser token manager or refresh flow in the current repository.