· 4 min read
How I Secured My Next.js Admin Panel: Argon2, Email Codes and Revocable Sessions
A walkthrough of the login behind my portfolio's admin panel: argon2id with a pepper, lockouts, email codes, and JWT sessions you can revoke, in Next.js 16.
Introduction
When I rebuilt this portfolio in Next.js, I wanted a small admin panel for writing posts. One person signs in, so it's tempting to hard-code a password in an environment variable and move on.
I didn't, for a simple reason: an admin panel on a public domain gets found. Bots scan /admin, /wp-admin and /login on every site they can reach. So I treated the login the way I would on a client project, but kept it small enough to read in one sitting.
Here is how it works and why each piece is there.
The login flow at a glance
- You enter an email and password.
- The server checks the password. If it's correct, it emails you a 6-digit code.
- You enter the code, and the server starts a 7-day session in an
httpOnlycookie.
Three parts, each with a separate job: passwords, a second factor, and sessions.
Passwords: argon2id plus a pepper
Passwords are hashed with argon2id, the current recommendation for password hashing. It's slow on purpose and uses a lot of memory, which makes brute-forcing a stolen hash expensive.
On top of that, I add a pepper: a secret string that lives in an environment variable, not in the database.
import argon2 from "argon2";
const pepper = process.env.AUTH_PEPPER ?? "";
export function hashPassword(password: string) {
return argon2.hash(password + pepper, { type: argon2.argon2id });
}
export function verifyPassword(hash: string, password: string) {
return argon2.verify(hash, password + pepper);
}
If someone ever gets a copy of the database, the hashes alone aren't enough to start guessing. They'd also need the server's environment.
Don't leak which emails exist
A common mistake is returning early when the email isn't found. The response comes back faster, and an attacker can use that timing to learn which emails have accounts.
So when there's no matching admin, I still run a hash check against a placeholder hash, and both cases return the same message: "Incorrect email or password."
Lockouts: slow down guessing
Every failed password increases a counter on the admin record. After 5 failed attempts, the account is locked for 15 minutes. A correct login resets the counter.
This is simple, but it turns a brute-force attack from thousands of guesses a minute into a handful per quarter hour.
A second factor by email
After the password check, the server generates a random 6-digit code and emails it with Mailgun. A few rules keep it safe:
- The code is hashed before it's stored, just like a password.
- It's valid for 5 minutes.
- After 5 wrong codes, there's a 15-minute cooldown.
- Between the two steps, a short-lived signed cookie remembers that the password was correct, so the code step can't be reached on its own.
An email code isn't as strong as an authenticator app or a passkey. But it means a leaked password alone doesn't get anyone in, and it needs no extra setup.
Sessions: signed cookies you can revoke
Once the code is correct, the server signs a JWT with jose and stores it in a cookie that is:
httpOnly, so JavaScript on the page can't read itsecure, so it's only sent over HTTPSsameSite: "lax", which blocks most cross-site request tricks
JWTs have one known weakness: you can't easily "log out" a token that's already been issued. I handle that with a session version number on the admin record. Each token carries the version it was created with. Resetting the password increases the version, and every older token stops working immediately.
Two layers of checks in Next.js
Next.js 16 has proxy.ts (previously middleware.ts), which runs before every /admin request. It does a quick check: is the cookie present, correctly signed and not expired? If not, it redirects to the login page.
That check can't see the database, so it can't catch revoked sessions. That's why every admin page and every server action also runs a full check that loads the admin and compares the session version:
export async function requireAdmin() {
const admin = await getCurrentAdmin();
if (!admin) redirect("/admin/login");
return admin;
}
Server actions matter here. They are public HTTP endpoints, and a request can reach one without ever loading the page. Protecting only the page isn't enough.
Small details that add up
- No sign-up page. The admin account is created with a command-line script, so there's no form to abuse.
noindexon everything under/admin, so the panel never shows up in search results.- Rate limits on the public forms (contact and newsletter), plus reCAPTCHA v3 to filter bots.
- Security headers such as
X-Frame-Optionsand a strictReferrer-Policy, set once innext.config.ts.
What I'd add next
If the panel ever has more than one user, I'd add passkeys (WebAuthn) and an audit log of sign-ins. For a single-author blog, the setup above is a good balance: secure enough that I don't worry about it, and small enough that I understand every line.
Wrapping up
Security for small projects doesn't need a heavy framework. It needs a few deliberate choices: slow password hashing, limits on guessing, a second factor, and sessions you can revoke.
If you want something similar for your product, or a second pair of eyes on an existing login, get in touch. You can also see the projects I've worked on here.
Get the next article in your inbox
One email per new post. Unsubscribe anytime.