LaWalletdocs
Guides

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/jwt and /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-here

Important: 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):

ClaimValue
isslawallet-nwc
audlawallet-users
iatIssued-at, Unix seconds
expExpiry, Unix seconds (iat + expiresIn)

Identity & authorization (session JWT and device tokens):

ClaimTypeMeaning
userIdstringActor's hex Nostr pubkey (doubles as the subject)
pubkeystringActor's hex Nostr pubkey
roleADMIN | OPERATOR | VIEWER | USERResolved role
permissionsstring[]Permissions for the role (device tokens mirror scopes here)

Device-token-only claims (POST /api/auth/qr-jwt/generate):

ClaimTypeMeaning
kind'device'Marks the token as a delegated device token
substringTarget user's DB id (for auditing)
scopesstring[]Delegated permission subset — authoritative for permission-gated routes (see Scopes)
apiUrlstringPlatform 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 expiresIn option becomes an exp claim — a Unix timestamp (iat + duration) that jsonwebtoken writes into the payload and the signature covers. A holder can't push it later without the JWT_SECRET.
  • On every request, verifyJwtToken (lib/jwt.ts) re-checks the token: it validates the signature and compares exp against the current server clock. Once now >= exp, verification throws and the request is rejected with 401. No timer, no cleanup — just a per-request check.
request → verify signature → now < exp ? → allow : 401

Expiry 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):

  1. Pick the user the device will act as.
  2. 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.
  3. Choose an expiration (1h · 8h · 24h · 7d · custom).
  4. 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 return 400, and granting a permission the caller lacks returns 403.
  • expiresIn — a ms-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's endpoint setting, or the request host the mint came in on when endpoint is unset (e.g. http://localhost:55067 in local dev). It is deliberately not the lightning-address domain — 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.

PermissionAllows
cards:readRead cards
cards:writeCreate, update, and pair cards
card_designs:readRead card designs
card_designs:writeCreate and update card designs
ntags:readRead NTAG424 keys
ntags:writeWrite NTAG424 keys
addresses:readRead Lightning addresses
addresses:writeCreate and update Lightning addresses
settings:readRead instance settings
settings:writeUpdate instance settings
users:readRead users
users:writeCreate and update users
users:manage_rolesChange a user's role
activity:readRead 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 the scopes array — it overrides the role→permission map. A token scoped to cards:read cannot write cards even if the target user's role normally could; conversely the admin can delegate a capability beyond the user's base role, because scopes is a subset of the admin's RBAC, not the user's.
  • The check is fail-closed: a malformed scopes claim, 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 example POST /api/cards requires ADMIN, 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 no scopes claim 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's apiUrl. A device token whose apiUrl is missing or doesn't match is rejected with 401 — 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.

On this page