QuizGPT is a self-hostable multi-user full-stack web app that generates quizzes from natural language prompts. Quizzes and attempts are persisted in PostgreSQL, quizzes can be retaken without re-calling the LLM, and the app supports source-grounded generation from uploaded documents and GitHub repositories.
It is designed for authenticated teams, not only for a single local operator. The current app includes:
- email/password auth and optional OIDC/SSO auth
- roles:
super_admin,admin,user - multi-user administration
- system and private providers/models with per-user access assignment
- quiz groups, regenerate flows, guest share links, review/results screens, admin and profile pages
In practice:
- users can create and manage their own quizzes and private resources
- admins can manage users and create shared system resources
- guest users can only access public share links, never the authenticated UI
- Backend: TypeScript + Express +
pg+ Vercel AI SDK (ai) - Frontend: React + Vite + TypeScript
- DB: PostgreSQL with SQL migrations auto-applied at backend startup
Why this stack: small operational surface, explicit SQL control, and provider abstraction without custom LLM boilerplate.
- Node.js 22+
- npm 10+
- PostgreSQL 16+
- Optional: Docker + Docker Compose
- Copy env file:
cp .env.example .env- Configure
.env(DATABASE_URLis required). For local non-Docker DB, changeDATABASE_URLhost fromdbto your local host (e.g.localhost). - Install deps:
npm install- Start backend + frontend:
npm run dev- Frontend:
http://localhost:5173 - Backend/API:
http://localhost:3000
The compose setup reads variables from .env (no hardcoded app secrets in compose files).
- Copy env:
cp .env.example .env- Choose mode:
docker compose pull
docker compose up -ddocker compose -f docker-compose.dev.yml up --buildApp is served at http://localhost:3000.
When you pass sourceText, uploaded docs, or githubRepoUrl, backend does:
- Extract and normalize text.
- Chunk corpus.
- Lexical pre-filter for candidate pruning.
- Remote embeddings retrieval through the embedding model configured in the app.
- Rank by semantic similarity + lexical prior.
- Send only top chunks to the LLM using the app's built-in retrieval budgets.
This avoids context explosion on big repositories.
Server logs are JSON lines controlled by LOG_LEVEL. They include startup config, generation inputs summarized by size/preview, provider/model settings, retrieval counts, attempts, and clear error messages without stack traces.
| Variable | Required | Default | Description |
|---|---|---|---|
SETTINGS_ENCRYPTION_KEY |
Yes | - | AES-256-GCM key for encrypting API keys stored via the Settings UI. A default is provided in .env.example — change it before deploying. Generate a new one with: openssl rand -hex 32 |
PORT |
No | 3000 |
Backend HTTP port |
PUBLIC_URL |
Yes | http://localhost:3000 |
Public app URL injected via runtime /config.js |
LOG_LEVEL |
No | info |
JSON log level: debug, info, warn, error |
JWT_SECRET |
Recommended | auto-generated | Static JWT signing key (set to persist sessions across restarts). Generate: openssl rand -hex 64 |
JWT_EXPIRY |
No | 7d |
JWT session duration |
RATE_LIMIT_WINDOW_MS |
No | 900000 |
Rate limit window for API routes |
RATE_LIMIT_MAX_REQUESTS |
No | 300 |
Max API requests per window |
GENERATE_RATE_LIMIT_MAX_REQUESTS |
No | 20 |
Max quiz generation requests per window |
DATABASE_URL |
Yes | - | PostgreSQL connection string |
GITHUB_TOKEN |
Optional | empty | Improves GitHub API rate limits/private access |
QuizGPT no longer reads global LLM or embedding credentials from environment variables. Generation and retrieval are configured inside the app:
- create one or more providers in the admin UI
- create LLM and embedding models linked to those providers
- assign access to the right users
- choose the default LLM and optional default embedding model from the app
- optionally override the selected models directly from the quiz generation pages
What this means operationally:
- if a user has no accessible default LLM and does not select one explicitly, quiz generation fails
- embeddings are used only when an embedding model is selected or configured as default
- retrieval budgets and batching are app defaults, not deploy-time env knobs
Common setups:
- OpenAI: create an
openaiprovider withhttps://api.openai.com/v1, then create an LLM model such asgpt-4oand, if desired, an embedding model such astext-embedding-3-small. - OpenAI-compatible: create an
openai_compatibleprovider pointing to Ollama, LM Studio, LiteLLM, vLLM, OpenRouter, or another compatible gateway, then create the matching LLM and embedding models in the UI. - Anthropic: create an
anthropicprovider for the Claude model you want, and if you need retrieval also create a separate embedding provider/model because Anthropic embeddings are not assumed by the app.
You can share any quiz with friends or colleagues without giving them access to your QuizGPT instance. From a quiz page, click the Share icon to generate a unique link. Optionally set a maximum number of attempts and an expiry date.
The guest experience is fully self-contained: the shared link loads a fullscreen quiz page that requires no login and does not use quiz-generation credentials. Answers are evaluated server-side against the already-generated questions stored in the database. Guest free-text answers are stored but treated as incorrect, with no AI evaluation or explanation returned.
All active share links and their attempt counts are visible under Shares in the sidebar, where you can also revoke them at any time.
Guest attempts are also visible to the quiz owner in Results and can be opened from the review screen just like regular attempts. This means owners can audit both their own attempts and guest attempts on the quizzes they created.
QuizGPT supports two authentication methods that can coexist:
QuizGPT acts as an OIDC Relying Party. Any OIDC-compliant provider works (Authelia, Authentik, Keycloak, Okta, Google, Azure AD, …).
Minimum setup:
- Create a client in your OIDC provider with redirect URI
https://<your-domain>/api/auth/callback/oidc - Create three groups:
quiz_super_admin,quiz_admin, andquiz_user - Assign each authorized user to one of the groups
- Set the
OIDC_*environment variables (see below)
Users not in one of the three groups will see an "Access Denied" page.
Important behavior of the current backend:
- the app expects the
groupsclaim to exist and contain one of the three QuizGPT groups - the first user to enter the system, whether via email or OIDC, is promoted to super admin
PUBLIC_URLandOIDC_REDIRECT_URImust match the externally reachable URL exactly- secure auth cookies are enabled automatically when
PUBLIC_URLstarts withhttps
Group mapping:
| OIDC group | QuizGPT role |
|---|---|
quiz_super_admin |
super_admin |
quiz_admin |
admin |
quiz_user |
user |
The app expects these claims:
subemail- optionally
nameorpreferred_username groups
Enable the groups scope in your OIDC provider, or otherwise ensure the ID token contains a groups claim.
OIDC_ISSUER=https://auth.example.com
OIDC_CLIENT_ID=quiz-gpt
OIDC_CLIENT_SECRET=xxx
OIDC_REDIRECT_URI=https://quiz.example.com/api/auth/callback/oidc
OIDC_SCOPE=openid profile email groupsFor Authentik:
- Create an OAuth2/OpenID Provider + Application for QuizGPT.
- Set the redirect URI to
https://<your-domain>/api/auth/callback/oidc. - Ensure the token exposes
sub,email, and agroupsclaim. - Map users into groups named exactly:
quiz_super_adminquiz_adminquiz_user
What usually matters most in Authentik is not only creating groups, but making sure those group values are actually emitted into the groups claim seen by QuizGPT.
For Keycloak:
- Create a confidential client for QuizGPT.
- Configure the valid redirect URI to
https://<your-domain>/api/auth/callback/oidc. - Add a mapper that exposes user group membership as a
groupsclaim. - Make sure users land in one of:
quiz_super_adminquiz_adminquiz_user
If you use realm roles or client roles internally, add a mapping layer so the final claim still matches the exact group names expected by QuizGPT.
For Authelia:
- Configure an OIDC client for QuizGPT.
- Use the same callback URL as above.
- Ensure
sub,email, andgroupsare exposed to the client. - Ensure the
groupsclaim contains one of:quiz_super_adminquiz_adminquiz_user
If Authelia uses different internal group names, translate them before they reach QuizGPT. The backend currently reads the groups claim literally.
Before blaming the app, verify:
PUBLIC_URLis correct and public-facingOIDC_REDIRECT_URIexactly matches the provider config- the ID token includes
subandemail - the ID token includes a
groupsclaim - one allowed QuizGPT group is actually present
- the app is behind HTTPS if you expect secure cookies in production
Enabled by default. Users register with email + password. Set DISABLE_EMAIL_REGISTER=true to disable.
DISABLE_EMAIL_REGISTER=true| Role | Permissions |
|---|---|
super_admin |
First user created. Everything an admin can do, plus cannot be deleted or demoted by anyone, including themselves. |
admin |
Manage users, providers and models, manage shared system resources, access admin surfaces |
user |
Manage own quizzes/groups/attempts, own private providers/models, and system providers/models explicitly assigned to them |
The app currently distinguishes between:
- private providers/models
- owned by one user
- visible and editable only by that owner
- system providers/models
- created for shared use
- managed by admins
- assignable to specific non-admin users
This means a non-admin user should only see:
- their own private models/providers
- shared system models/providers assigned to them
Public share links (/public/s/:token) and static assets remain unauthenticated. The OIDC middleware only protects /api/* routes (excluding /api/auth/* and /api/health).
- If you want LiteLLM, expose it as an
openai_compatibleprovider inside the app. - For very large/private GitHub repos, set
GITHUB_TOKEN. - On every push to
main, GitHub Actions builds and publishes the app image toghcr.io/paperized/quiz-gpt. - The default
SETTINGS_ENCRYPTION_KEYin.env.exampleis public — always replace it with your own before deploying:openssl rand -hex 32.