How to Secure an Admin Panel: Google Auth, 2FA, and Sessions on Vercel
The admin panel is the most valuable door in your product. One weak login there and an attacker does not get a single customer record, they get all of them, plus the payouts, the content, and the ability to lock you out of your own system. Yet most admin breaches are not exotic. They are a missing role check on an API route, a token sitting in browser storage, or an admin page protected only by the hope that nobody knows the URL.
This is the technical version of how to do it properly, written for the common setup where both your customer-facing app and your admin live on Vercel. It covers how to lay the two out, how to sign admins in with Google and an allowlist, how to add a second factor that actually resists phishing, why httpOnly cookies beat tokens in the browser, and the server-side checks that are the part that genuinely keeps people out. The examples use Next.js because that is what most Vercel stacks run, but the principles carry to any framework.
Separate the client and the admin, even on one Vercel account
The first decision is layout. You have two reasonable patterns, and both are perfectly fine to run on a single Vercel account.
The simplest is one Next.js app with the admin under an /admin path, gated by middleware. It is quick to build and easy to reason about, but the public site and the admin share a domain, a cookie scope, and an attack surface, which is more than you want for anything sensitive.
The safer pattern is a separate Vercel project on its own subdomain, something like admin.yourapp.com. This is worth the small extra effort because it buys real isolation:
- You can scope the admin session cookie to the admin host, so a cross-site scripting bug on the public marketing site can never ride the admin session.
- Admin-only secrets, service keys, and database credentials live only in the admin project, never shipped to the public bundle.
- The admin talks to your database through a least-privilege service role that is distinct from the role the public app uses.
Both apps still deploy to the same Vercel account, so this costs you nothing operationally. The separation is about domain, cookies, environment variables, and database privileges, not about needing a different host.
Sign admins in with Google, then check an allowlist
Google sign-in is a strong choice for admins. It hands the password problem, and a lot of the phishing problem, to an identity provider that does it better than you will. Use a library like Auth.js (NextAuth) to wire it up.
The critical point most people miss: a successful sign-in is authentication, not authorization. Google tells you who someone is. It does not tell you they are allowed to run your business. So the moment after sign-in, verify the email is verified and on an explicit allowlist, server-side, in the signIn callback.
// auth.ts (Auth.js)
callbacks: {
async signIn({ profile }) {
const allowed = new Set(["lior@7it.co.il", "ops@7it.co.il"]);
// require a verified Google email AND membership in the allowlist
return profile?.email_verified === true && allowed.has(profile?.email ?? "");
},
}
For a team on Google Workspace you can also check the hosted-domain claim, but still keep an explicit list of who is actually an admin. Never grant admin access simply because someone has a Google account. And store each admin's role in your own database, not just in the Google profile. Google decides who they are, your database decides what they can do.
Add a second factor that actually resists phishing
Even behind Google, require a second factor for admin access. If one account is compromised or a password was reused somewhere, that single factor is all that stands between an attacker and everything.
There are three tiers, and they are not equal:
- Passkeys and security keys (WebAuthn) are phishing-resistant, because the credential is cryptographically bound to your domain and cannot be replayed on a lookalike site. This is the one to aim for.
- Authenticator-app codes (TOTP) are a solid step up from nothing, but the six-digit code can still be phished by a convincing fake login page, so treat them as good, not best.
- SMS codes are the weakest, vulnerable to SIM swaps and interception. Avoid them for admin.
A strong setup enforces 2FA at the Google level as a baseline and adds a WebAuthn step in the admin app itself, then re-prompts (step-up auth) before the most dangerous actions: issuing refunds, exporting data, or changing another admin's role.
Sessions: cookies vs tokens, and why it matters here
This is where good intentions go wrong most often. The single most common admin mistake is storing a session token, usually a JWT, in localStorage.
Anything in localStorage or sessionStorage is readable by any JavaScript running on the page. There is no httpOnly equivalent for it. So one cross-site scripting bug, or one compromised npm dependency, and an attacker reads the token, copies it, and becomes the admin from their own machine, later, at their leisure.
An httpOnly cookie cannot be read by JavaScript at all. A script injection can still try to act inside the open page, but it cannot exfiltrate the session to reuse elsewhere, which is the difference between a contained incident and a stolen account. So for an admin panel, the session belongs in an httpOnly, Secure, SameSite cookie.
cookies().set("admin_session", sessionId, {
httpOnly: true, // invisible to JavaScript, so XSS cannot read it
secure: true, // sent only over HTTPS
sameSite: "lax", // not sent on cross-site requests, blunts CSRF
domain: "admin.yourapp.com",
path: "/",
maxAge: 60 * 30, // short lived, refresh on activity
});
Prefer an opaque session id backed by a server-side session store (your database or Redis) over a self-contained JWT, because you can revoke a server session the instant you need to. A stateless JWT stays valid until it expires, you cannot truly log it out. If you do use JWTs, keep them short-lived with rotating refresh tokens, and still keep them in httpOnly cookies, never in storage.
One honest caveat: bearer tokens are the right tool for service-to-service calls and mobile apps, where there is no browser and no scripting surface to leak from. The cookie rule here is specifically about code running in a browser, which is exactly where an admin panel lives.
Gate every route on the server, not just the UI
Hiding the admin link or checking the role in your React components is convenience, not security. Anyone can call your API directly with the cookie they already have. Every protected page, every API route, and every server action must independently verify the session and the role on the server.
Middleware is a useful first gate:
// middleware.ts
export const config = { matcher: ["/admin/:path*"] };
export function middleware(req) {
const session = req.cookies.get("admin_session")?.value;
if (!session) {
return NextResponse.redirect(new URL("/login", req.url));
}
// middleware only checks presence; the route still verifies and authorizes
}
Middleware only proves a cookie exists. The real check happens inside the handler or server action, every time:
- look up the session in your store and confirm it is valid, not expired, not revoked
- load the user and their role
- confirm the role allows this specific operation
- only then read or write data
Apply least privilege throughout. An admin who manages content should not be able to issue a refund. Define real roles and check the specific permission, not one catch-all isAdmin boolean.
Harden the perimeter: headers, CSRF, rate limits, secrets
With auth and sessions handled, close the common side doors:
- Security headers. Set a strict Content-Security-Policy, which is your best defense against the XSS that would otherwise abuse a session, plus HSTS,
X-Content-Type-Options: nosniff, a sensible Referrer-Policy, andframe-ancestors 'none'so the admin cannot be embedded and clickjacked. Configure them innext.configorvercel.json. - CSRF. SameSite cookies blunt it, but for state-changing requests also use your framework's CSRF protection or require a custom header. Never perform a mutation on a GET request.
- Rate limiting and lockout. Throttle login and sensitive endpoints, using the Vercel edge with a store like Upstash, to shut down brute force and credential stuffing.
- Secrets. Keep API keys and database credentials in Vercel environment variables scoped to the admin project, and never prefix them with
NEXT_PUBLIC_, which ships them straight to the browser. Use separate keys for preview and production, and rotate them. - Preview deployments. Vercel preview URLs are public by default, so protect them with Vercel authentication. A staging admin left open is a real admin left open.
See what happens: audit logs, alerts, and fast revocation
Prevention fails sometimes, so build for the moment it does.
- Log every admin action, who did what, when, and from where, to an append-only audit trail. When something looks wrong, this is the difference between knowing and guessing.
- Alert on the unusual: a new device, a sign-in from a new country, a burst of failed 2FA, a sudden bulk export.
- Be able to revoke instantly. Kill a single session, force everyone to re-authenticate, or rotate a key. With server-side sessions, each of those is one query, which is exactly what you want at three in the morning.
The short version (admin security checklist)
- Admin on its own subdomain, with its own environment variables and a least-privilege database role.
- Google sign-in plus an explicit allowlist, with the real role stored in your own database.
- A phishing-resistant second factor (a passkey or security key), with step-up auth before dangerous actions.
- Session in an httpOnly, Secure, SameSite cookie, ideally an opaque id you can revoke. No tokens in localStorage.
- Server-side session and role checks on every page, API route, and server action. The UI is not a security control.
- Strict security headers, CSRF protection, and rate limiting on authentication.
- Secrets only in server-side environment variables, protected preview deployments, and rotated keys.
- Audit logs, anomaly alerts, and instant revocation.
FAQ
Is signing in with Google enough to secure an admin panel?
No. Google proves who someone is, but not that they should be an admin, authentication is not authorization. You still need an allowlist or a role stored in your own database to decide who gets in, a second factor, and server-side role checks on every action. Google is one strong layer, not the entire wall.
Should I store the session token in localStorage or in a cookie?
Use an httpOnly, Secure, SameSite cookie. Anything in localStorage is readable by JavaScript, so a single cross-site scripting bug or compromised dependency hands an attacker the admin session to reuse whenever they like. An httpOnly cookie cannot be read by scripts at all, which stops a script injection from becoming a stolen account. Reserve bearer tokens for service-to-service or mobile, where there is no browser storage to leak from.
Do I still need 2FA if admins sign in with Google?
Yes, and ideally a phishing-resistant kind. If one Google account is compromised or its password was reused, that single factor is all that protects your data. A passkey or security key bound to your admin domain defeats the phishing that beats passwords and one-time codes. At a minimum, enforce 2FA at the Google level, and better, add a WebAuthn step in the admin app itself.
Can the client app and the admin live on the same Vercel project and domain?
They can, and many do, but putting the admin on its own subdomain is safer. A separate host lets you scope the admin session cookie so a scripting bug on the public site cannot ride it, keep admin-only secrets and database roles isolated, and shrink the admin's attack surface. Both still run on the same Vercel account, the separation is about domain, cookies, environment, and database privileges.
How do I protect admin API routes, not just the pages?
Check the session and role inside every route handler and server action, not only in middleware or the interface. Middleware is a useful first gate, but an attacker can call your API directly, so each endpoint must independently verify the session against your store, load the role, and confirm it allows that specific operation before touching data. Treat every request as untrusted until the server has proven otherwise.
If you are building or already running an admin panel on Vercel and want a second set of eyes on the auth, sessions, and access model before something goes wrong, tell me how it is set up and I will give you a straight technical read on where the real gaps are.
Want a hand applying this?
Tell me where your business is stuck and I will give you a straight, useful read, no pitch.