IDmint.
A proof of concept showing how trivially the digital student IDs now sold as innovation can be forged. It mints QR-verified cards you show straight from your phone that pass the same mobile check page as the real thing, exposing that the verification adds no real security.


IDmint is a working proof of concept that mints digital, QR-verified student ID cards you show straight from your phone. The tool itself is properly built and locked down, a full Next.js 16 App Router stack with Postgres, Prisma, server-rendered cards, and constant-time staff auth, so only I can mint with it. The point it proves sits one level up: universities and vocational schools are rolling out exactly this kind of phone-shown digital ID, and a card I forge passes the same public mobile check as a real one. So the ID verification adds no real security. The only thing actually hardened here is my own tool, not the credential it produces.
The card is a digital credential meant to be shown on a phone, never printed. It is rendered server-side with @react-pdf/renderer at fixed point dimensions rather than exported from a browser: the route loads the card and its institution from Prisma, generates a QR code pointing at the public check URL, re-reads the cropped photo and logo from disk as base64 data URIs, and renders a decorative vertical Code 128 strip via bwip-js rotated 90 degrees so it drops straight into a stretched flex column. Every glyph, the 3:4 portrait, the address block, and the logo are laid out in points and capped so aspect ratios are preserved, producing a deterministic single-page card that renders identically on any screen.
Localization is modeled as a property of the card, not the request. Each card stores a locale column (de, fr, or it) that drives both outputs, the card text and the public check page, because the card is re-rendered on demand and the check page is opened by whoever scans the QR on their own phone, so neither can infer the issuing language from the browser. Message catalogs live in src/lib/messages with a typed contract shared across all three languages.
Auth is deliberately small and correct. A single staff account logs in against AUTH_USERNAME and AUTH_PASSWORD compared in constant time with a hand-written comparator that never short-circuits and folds the length difference into the result, so timing cannot reveal which field was wrong. Success mints a 7-day httpOnly JWT session signed with jose, and a Next 16 proxy guards the staff page and API prefixes, redirecting pages to login and returning 401 for API routes. Brute force is throttled with a 5-attempt, 15-minute in-memory IP lockout. The public check route uses an unguessable random token rather than the human ID number, so check URLs cannot be enumerated.
The same codebase ships as two separate deployments through a build-time route splitter. scripts/prune-routes.mjs reads APP_ROLE and physically deletes the other deployment's routes before next build, so the public check deployment only contains the check page and photo API while the staff deployment only contains the admin UI and protected APIs. Missing routes 404 naturally and no runtime gating is needed. Uploaded photos are normalized through sharp to a 450x600 JPEG with EXIF honored then stripped, and form input is validated end to end with Zod including the 3-4-3 ID number format and a valid-from/valid-until ordering refinement.