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"
}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"
}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, simple-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 are bounded server-side to 1 minute – 30 days — 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. Values outside1m – 30dare rejected.
Response:
{
"jwt": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": "8h",
"scopes": ["cards:read", "cards:write", "ntags:write"],
"user": { "id": "clx0abcd1234", "pubkey": "<hex-pubkey>", "role": "OPERATOR" }
}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.
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.