Admin Roles & Authorization
Configure read-only and read/write admin roles, protect admin APIs, and understand the authentication pipeline used by this template.
Authentication & Admin
This template uses Better Auth + Drizzle for authentication and sessions. A simple, DB‑backed RBAC layer adds two admin roles on top of regular users. Roles are stored in the database only (no env-based elevation).
Basics: Authentication vs. Authorization
- Authentication: Proves who you are (sign‑in/sign‑up). The result is a session.
- Authorization: Decides what you can do after sign‑in. In this template, a
role
on theusers
table controls access.
Roles we use:
user
– default for everyone.admin_ro
– read‑only admin (can view admin data, no writes).admin_rw
– read + write admin (includes granting credits and other admin actions).
A simple analogy
At an airport, showing your ID to security is authentication (proving identity). Presenting your boarding pass at the gate is authorization (verifying you’re allowed on that flight).
Quick comparison
Topic | Authentication | Authorization |
---|---|---|
Purpose | Verify identity (who you are) | Verify permissions (what you can do) |
When | Before authorization | After successful authentication |
Typical inputs | Credentials (password, email link, social sign‑in) | Policies/roles (e.g., user , admin_ro , admin_rw ) |
Output | A session/identity | Allowed actions and protected routes |
Common standards (general) | OpenID Connect (OIDC), ID tokens | OAuth 2.0, access scopes |
In this template: we use secure, HttpOnly session cookies on the server (not front‑end tokens) and a simple role field on users
to drive authorization checks.
What is Better Auth?
Better Auth is a lightweight, TypeScript‑first auth library for Next.js.
- Storage: Uses Drizzle ORM + Postgres via the adapter configured in
src/lib/auth.ts
. - Data model:
users
,sessions
,accounts
,verifications
insrc/db/schema.ts
. - Sessions: Sets an HttpOnly cookie and persists session rows in the
sessions
table; server routes read the session viaauth.api.getSession({ headers })
. - Extra fields: We expose
uuid
androle
on the session throughadditionalFields
for easy server checks.
Secrets and URLs (entry‑level)
BETTER_AUTH_SECRET
: A long, random server‑only secret used to sign/verify auth tokens and secure cookies. Generate one time and keep it private.- Generate:
openssl rand -base64 32
- Rotation note: Changing it logs out all users because existing sessions can no longer be verified.
- Generate:
BETTER_AUTH_URL
: Server base URL used by the auth library (usually your site URL).NEXT_PUBLIC_AUTH_BASE_URL
: Client‑side base (keep same as site URL in most cases).- Cookies are
HttpOnly
andsecure
in production (seeadvanced.defaultCookieAttributes
insrc/lib/auth.ts
).
If sign‑in seems to work only once or sessions vanish after restart, double‑check these envs and restart the dev server.
Auth Pipeline Overview
- Library: Better Auth (
src/lib/auth.ts
) with Drizzle adapter to Postgres (src/db/schema.ts
). - Users are stored in
users
. Sessions, accounts, and verifications live insessions
,accounts
,verifications
. - The session is fetched in App Router via
auth.api.getSession({ headers })
. - Custom user fields are exposed to the session via
additionalFields
(we includeuuid
androle
).
Roles
user
: default for everyone.admin_ro
: read‑only admin; can list users, list orders, and view a user’s credit summary.admin_rw
: read + operation admin; includes all read‑only powers and can grant credits to users (trans_type = system_add
).
Table changes (migration 0001_add_user_role.sql
) add users.role
with default user
.
How Authorization Works
- Helpers in
src/lib/authz.ts
derive the current user’s role from the session/DB only:requireAdminRead()
→ allowsadmin_ro
/admin_rw
.requireAdminWrite()
→ allowsadmin_rw
.
Admin APIs
All endpoints are server‑only and role‑guarded:
GET /api/admin/users
→ list users. Query:page
,limit
.GET /api/admin/orders
→ list paid orders. Query:page
,limit
.GET /api/admin/users/:uuid/credits
→ credit summary + recent ledger.POST /api/admin/credits/grant
→ grant credits assystem_add
.
// POST /api/admin/credits/grant body
{
"userUuid": "<target-user-uuid>",
"credits": 100,
"expiredAt": null, // optional ISO string or null
"orderNo": "", // optional
"note": "promo Q4" // reserved for future auditing
}
Setup Steps
- Migrate DB
Run migrations to add the role
column:
pnpm drizzle-kit generate --config src/db/config.ts
pnpm drizzle-kit migrate --config src/db/config.ts
This repo includes src/db/migrations/0001_add_user_role.sql
so a generate is optional if you don’t change the schema further.
- Verify endpoints
- Call
GET /api/admin/users
as an admin to confirm access. - Call
POST /api/admin/credits/grant
with a targetuserUuid
to add credits; the ledger showstrans_type = system_add
.
Changing Roles
Update roles in the database (authoritative source):
- SQL (direct):
update users set role = 'admin_rw' where uuid = '<uuid>';
- Update via code helper:
updateUserRole(uuid, 'admin_ro' | 'admin_rw' | 'user')
insrc/models/user.ts
.
Notes & Tips
- The existing dev endpoint
POST /api/account/credits/grant
is for local testing; lock it down or remove in production in favor of the admin endpoint above. - Consider adding an audit log table if you need to track who granted credits (operator UUID, timestamp, reason).
- Protect admin accounts with MFA/SSO and shorter session TTLs.
- Only expose admin UI/pages after guarding them on the server (check role in the server component and redirect if unauthorized).
- Roles are not controlled by environment variables; use DB changes for assignment/revocation.
Social Sign‑In (Google)
- The Google provider is configured in
src/lib/auth.ts
undersocialProviders.google
and readsGOOGLE_CLIENT_ID
/GOOGLE_CLIENT_SECRET
from.env
. - Ensure these envs are set:
GOOGLE_CLIENT_ID
,GOOGLE_CLIENT_SECRET
, plusBETTER_AUTH_URL
andNEXT_PUBLIC_AUTH_BASE_URL
(e.g.http://localhost:3000
in development). - In Google Cloud Console → Credentials, add the Authorized redirect URI:
http://localhost:3000/api/auth/callback/google
(dev)https://your-domain.com/api/auth/callback/google
(prod)
- The login screen at
/[locale]/login
shows a “Continue with Google” button that starts the OAuth flow.
Email & Password
This template enables Better Auth’s email & password authenticator and includes localized “Forgot password” and “Reset password” pages.
- Server config lives in
src/lib/auth.ts
→emailAndPassword
is enabled and wired withsendResetPassword
andonPasswordReset
callbacks. - Emails are sent via Resend using
src/services/email/send.ts
and the templatesrc/services/email/templates/reset-password.tsx
. - UI routes are localized under the App Router:
/[locale]/forgot-password
→ request reset link/[locale]/reset-password?token=...
→ set new password
- All UI strings come from
messages/*.json
under theauth.*
namespace (seemessages/en.json
).
Client usage
// Request a reset link
await authClient.requestPasswordReset({
email: "john.doe@example.com",
redirectTo: "/en/reset-password", // locale-aware in app
});
// Reset password (token comes from the reset link)
await authClient.resetPassword({
newPassword: "newpassword1234",
token,
});
Pages & Components
- Components:
src/components/auth/forgot-password-form.tsx
,src/components/auth/reset-password-form.tsx
(client components; i18n withnext-intl
). - Pages: thin wrappers at
src/app/[locale]/(auth)/forgot-password/page.tsx
andsrc/app/[locale]/(auth)/reset-password/page.tsx
render the components.
Environment
- Required:
BETTER_AUTH_URL
,RESEND_API_KEY
,EMAIL_FROM
. - Optional:
NEXT_PUBLIC_AUTH_BASE_URL
for client in edge cases.
Manual check
- Visit
/[locale]/forgot-password
, submit a known user email. - Open the email and follow the link to
reset-password?token=...
. - Set a new password and sign in at
/[locale]/login
.
Database Setup
Configure Postgres and Drizzle ORM — the backbone for auth, billing, storage, tasks, and app tables.
Stripe Setup
Use Stripe Checkout for payments and finalize via webhooks to grant credits. Configure environment variables, create sessions, handle callbacks, and process signed webhook events.