Senior Fullstack Interview: Auth & Security with Sessions, JWT, CSRF, XSS and OAuth2
Bài này có bản tiếng Việt. Đọc bản tiếng Việt →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Everyone has built a login screen, so everyone thinks they know Auth. And that’s exactly why it sorts candidates so well in the Senior Fullstack round. Someone who only knows “use JWT, store it in localStorage” answers one way. Someone who understands the threat model answers very differently.
Here are seven questions I run into often, in the same format as before: the question, the weak answer, the strong answer, then a trap follow-up. Go on the backend, React/TypeScript on the frontend. The stories here are pulled together from several sessions and aren’t tied to any particular company.
1. “Session or JWT?”
The weak answer: “JWT is more modern, it’s stateless so it scales better.” The moment you finish that sentence, the interviewer is getting ready to dig in.
The strong answer: the two solve slightly different problems.
- Session ID (opaque token): a random string with no meaning, and the server keeps state (Redis, DB) mapping the ID to a user. Big upside: instant revocation, just delete the record; permission changes take effect immediately; small size. Downside: every request has to hit the store. Usually that’s a single Redis read, which is cheaper than many people think.
- JWT (self-contained): the server signs the claims, and afterwards only needs to verify the signature, no store lookup. Upside: other services in the system can verify it themselves without calling the auth service. Downside: you can’t revoke it before it expires unless you build a denylist. And a denylist is state, which puts you close to sessions again.
Experienced people usually say it straight: for a web app with one backend, a session cookie is simpler and safer. JWT fits service-to-service identity propagation, or short-lived access tokens (5-15 minutes) in a multi-service architecture, combined with a refresh token to keep the session alive.
If you use JWT, verify it properly. Two classic mistakes: accepting alg: none, and algorithm confusion, where an attacker switches RS256 to HS256 and uses the public key as the secret. The server must always pin the algorithm:
type Claims struct {
Role string `json:"role"`
jwt.RegisteredClaims
}
func parseAccessToken(raw string, key []byte) (*Claims, error) {
claims := &Claims{}
tok, err := jwt.ParseWithClaims(
raw, claims,
func(t *jwt.Token) (any, error) { return key, nil },
jwt.WithValidMethods([]string{"HS256"}), // only accept the algorithm we chose
jwt.WithIssuer("api.example.com"),
jwt.WithAudience("web"),
jwt.WithExpirationRequired(),
)
if err != nil || !tok.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
The library here is github.com/golang-jwt/jwt/v5. Don’t put sensitive data in the payload. A JWT is signed, not encrypted. Anyone holding the token can read the claims with a base64 decode.
Trap follow-up: “What if you want immediate logout with JWT?” Be honest: there’s no free way. A few options: very short-lived access tokens plus revoking the refresh token, accepting a window of a few minutes; a denylist by jti in Redis; or a per-user token_version, compared on every request. The last two both carry state. A senior says so plainly instead of clinging to the “stateless” label.
2. Refresh token rotation and the two-tab trap
The interviewer asks: “Why do we need refresh tokens? What if a refresh token leaks?”
The weak answer: “Refresh tokens live longer so users don’t have to log in again.” Half right. The part that gets skipped is the interesting one: theft detection.
The strong answer: split the two roles. The access token is short-lived and sent on every request, so the leak risk is high but the damage is limited. The refresh token is long-lived and only sent to the refresh endpoint, so its exposure surface is much smaller. Rotation means that on every successful refresh, the server issues a new refresh token and invalidates the old one. The real value is in reuse detection. If a refresh token that’s already been used gets presented again, either a thief has it or the real user is holding an old copy, and you can’t tell which. So the handling is: revoke the whole family, meaning the chain of tokens born from one login, and force a re-login.
In the implementation, store a hash of the refresh token. SHA-256 is enough, because the token is 256 bits of randomness and doesn’t need bcrypt. And marking it “used” has to be atomic, so two concurrent requests can’t both win.
var ErrReuse = errors.New("refresh token reuse detected")
type RefreshRecord struct {
ID string
FamilyID string
UserID string
Used bool
Revoked bool
ExpiresAt time.Time
}
type RefreshStore interface {
FindByHash(ctx context.Context, hash string) (*RefreshRecord, error)
// UPDATE ... SET used = true WHERE id = $1 AND used = false
// returns true if exactly one row was changed
MarkUsed(ctx context.Context, id string) (bool, error)
RevokeFamily(ctx context.Context, familyID string) error
Insert(ctx context.Context, rec RefreshRecord, hash string) error
}
func hashToken(t string) string {
sum := sha256.Sum256([]byte(t))
return hex.EncodeToString(sum[:])
}
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func (s *AuthService) Rotate(ctx context.Context, presented string) (access, refresh string, err error) {
rec, err := s.store.FindByHash(ctx, hashToken(presented))
if err != nil || rec == nil || rec.Revoked || time.Now().After(rec.ExpiresAt) {
return "", "", errors.New("invalid refresh token")
}
ok, err := s.store.MarkUsed(ctx, rec.ID)
if err != nil {
return "", "", err
}
if !ok { // already used: could be theft, revoke the whole family
_ = s.store.RevokeFamily(ctx, rec.FamilyID)
return "", "", ErrReuse
}
refresh, err = newToken()
if err != nil {
return "", "", err
}
next := RefreshRecord{
ID: uuid.NewString(),
FamilyID: rec.FamilyID, // same family
UserID: rec.UserID,
ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
}
if err = s.store.Insert(ctx, next, hashToken(refresh)); err != nil {
return "", "", err
}
access, err = s.issueAccessToken(rec.UserID, 10*time.Minute)
return access, refresh, err
}
Trap follow-up: “What if two tabs refresh at the same time? Do you lock the user out of their own account?” It’s a real problem. Two requests carry the same refresh token, one wins, the other is treated as reuse, and the whole family gets destroyed. You mitigate it like this: on the client, use single-flight so only one refresh runs at a time; on the server, you can add a short grace window of a few seconds, letting a just-used token be presented again without triggering revocation. It’s a trade-off between safety and experience. A senior brings up both sides.
let inflight: Promise<boolean> | null = null;
export function refreshOnce(): Promise<boolean> {
inflight ??= fetch("/auth/refresh", { method: "POST", credentials: "include" })
.then((r) => r.ok)
.finally(() => { inflight = null; });
return inflight;
}
export async function apiFetch(input: RequestInfo, init: RequestInit = {}) {
const opts = { ...init, credentials: "include" as const };
let res = await fetch(input, opts);
if (res.status === 401 && (await refreshOnce())) {
res = await fetch(input, opts); // retry exactly once
}
return res;
}
3. Cookie flags: HttpOnly, Secure, SameSite, and where everyone gets confused
The interviewer asks: “Where do you store the token: localStorage or a cookie?”
The weak answer: “Cookies are safer.” Full stop. No reasoning attached.
The strong answer: each place defends against one kind of attack and is exposed to another.
- localStorage/sessionStorage: readable by JavaScript, so any XSS can steal the token and send it elsewhere. On the plus side, it isn’t attached to requests automatically, so it isn’t exposed to CSRF.
HttpOnlycookie: JS can’t read it throughdocument.cookie, so XSS can’t steal the token value. But the browser attaches cookies to requests on its own, so you’re exposed to CSRF unless you defend against it. Note that XSS can still use the session by sending requests straight from the compromised page.HttpOnlyonly stops the token from being carried off.
Three flags to remember:
HttpOnly: blocks JS from reading the cookie.Secure: only sent over HTTPS.SameSite:Strict(not sent on cross-site requests, even when following a link from another site),Lax(sent on top-level GET navigations, and Chrome’s current default when unspecified),None(always sent, and must come withSecure).
Add the __Host- prefix to have the browser enforce it: the cookie must be Secure, Path=/, and have no Domain attribute. In other words, other subdomains can’t overwrite it.
func setSession(w http.ResponseWriter, sid string) {
http.SetCookie(w, &http.Cookie{
Name: "__Host-sid",
Value: sid,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int((8 * time.Hour).Seconds()),
})
}
Trap follow-up: “Is SameSite=Lax enough to stop CSRF?” Don’t rely on it alone. Lax still sends the cookie on top-level GET navigations, so a GET must never change state. Some older browsers don’t support it. And “same-site” is computed per site (eTLD+1), not per origin, so a hijacked subdomain (subdomain takeover) is still same-site with you. That’s why you need the second layer in the next section.
4. CSRF: the browser attaches cookies by itself, and that’s the root of it
The interviewer asks: “How does CSRF work and how do you defend against it?”
The weak answer: “Use a CSRF token.” Correct, but not enough. They can’t explain why the token works, and they can’t name any other approach.
The strong answer: CSRF exploits exactly one thing: the browser attaches cookies to every request to the target domain, including requests that originate from the attacker’s page, for example a hidden form that auto-submits to bank.example/transfer. The server can’t tell whether the request was deliberate or tricked. Two preconditions: authentication relies on cookies, and a state-changing endpoint accepts the kind of request another page can create, namely a form POST.
The defensive layers, which you can combine:
SameSite=Lax/Strictas the first barrier.- Check
Origin(orReferer) on every state-changing request. Modern browsers always sendOriginon cross-origin POSTs, and an attacker can’t forge this header from another page’s JS. TheSec-Fetch-Siteheader is useful too. - Synchronizer token or double-submit cookie: the client must send a secret value, through a custom header, that the attacking page can’t read. A custom header also forces the request through a CORS preflight, which a foreign page can’t pass unless the server allows it.
- Never change state with GET.
func CSRF(allowedOrigin string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
if r.Header.Get("Origin") != allowedOrigin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if r.Header.Get("X-Requested-With") != "fetch" { // custom header forces a preflight
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
A request with no Origin header, such as an old client or a non-browser tool, gets rejected by the middleware above. That’s a safe default for an API that only serves browsers.
Trap follow-up: “Does an API that uses JWT in the Authorization header need CSRF protection?” In principle no, because the browser doesn’t attach that header on its own. But a JWT in a cookie still does. CSRF depends on how the credential is attached, not on the token format.
5. XSS and CSP: escaping alone isn’t enough
The interviewer asks: “How many kinds of XSS are there? How do you defend beyond escaping?”
The weak answer: “Escape user input.” That’s missing two things: where to escape, and what happens if something slips through.
The strong answer: three kinds. Stored: the payload is saved in the DB and shown to other people. Reflected: the payload comes from the URL and is echoed back into the page. DOM-based: client-side JS writes untrusted data into a dangerous sink like innerHTML, document.write, eval. The one rule to remember: escape for the output context. HTML body, attribute, URL, JS and CSS each work differently. Don’t escape “once, on input”. React escapes text by default, but there are still escape hatches:
// Dangerous: writing raw HTML
<div dangerouslySetInnerHTML={{ __html: comment.html }} />
// If you must render user-generated HTML, sanitize with a battle-tested library
import DOMPurify from "dompurify";
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment.html) }} />
// Also dangerous: an href from user data can be "javascript:..."
<a href={user.website}>Website</a>
For href, parse with new URL() and only allow http:/https:.
The defense-in-depth layer is Content Security Policy (CSP): a header telling the browser which script sources may run. The policy should be strict and nonce-based rather than a domain whitelist. Whitelists are easy to bypass through JSONP, or through a CDN that hosts a library allowing arbitrary code execution:
func CSP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b := make([]byte, 16)
_, _ = rand.Read(b)
nonce := base64.StdEncoding.EncodeToString(b) // a fresh nonce per response
w.Header().Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"script-src 'self' 'nonce-" + nonce + "' 'strict-dynamic'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
}, "; "))
ctx := context.WithValue(r.Context(), nonceKey{}, nonce) // the template pulls the nonce out and puts it on <script nonce="...">
next.ServeHTTP(w, r.WithContext(ctx))
})
}
type nonceKey struct{} needs to be declared in the same package. The nonce has to be random, unguessable and change on every response; a fixed nonce is pointless. The two directives object-src 'none' and base-uri 'none' block two attack paths that often get forgotten. Roll it out with Content-Security-Policy-Report-Only first, to see what breaks.
Trap follow-up: “Can CSP replace sanitizing?” No. CSP reduces the damage when XSS slips through, but it doesn’t remove the vulnerability. HttpOnly plus CSP also doesn’t stop an attacker from taking actions on the user’s behalf from within the page. Multiple layers means each layer assumes the previous one has already been breached.
6. OAuth2, OIDC and PKCE: which one does what
The interviewer asks: “How do OAuth2 and OIDC differ? What does PKCE solve?”
The weak answer: “OAuth2 is for logging in with Google.” The classic mix-up.
The strong answer: OAuth2 is an authorization protocol. The application gets an access token to call APIs on the user’s behalf, and the protocol says nothing about who the user is. OIDC (OpenID Connect) is the identity (authentication) layer built on OAuth2: it adds an ID token, a JWT containing sub, iss, aud, exp, nonce, plus the userinfo endpoint and the openid scope. “Sign in with Google” is OIDC.
The standard flow for web apps and SPAs today is Authorization Code + PKCE. PKCE exists to stop one thing: the authorization code travels through a redirect (the URL), so it can be intercepted, by a malicious app, in logs, in history. Without PKCE, anyone who intercepts the code can exchange it for a token. With PKCE, the client generates a random code_verifier, sends code_challenge = BASE64URL(SHA256(verifier)) at the start, then sends the real verifier when exchanging the code for a token. Intercepting the code without the verifier is useless. PKCE is recommended for every kind of client, including confidential ones. The implicit flow, where the token is returned directly in the URL, has been dropped for this reason.
Two more parameters that often get forgotten. state protects the login flow itself from CSRF (login CSRF). nonce binds the ID token to the login session that was just initiated, preventing replay.
// Start login
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
state, _ := newToken()
nonce, _ := newToken()
verifier := oauth2.GenerateVerifier() // golang.org/x/oauth2 >= v0.13
h.pending.Save(state, pendingAuth{Verifier: verifier, Nonce: nonce}) // tied to the browser session, short TTL
url := h.oauth.AuthCodeURL(state,
oauth2.S256ChallengeOption(verifier),
oidc.Nonce(nonce),
)
http.Redirect(w, r, url, http.StatusFound)
}
// Callback
func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
p, ok := h.pending.Take(r.URL.Query().Get("state")) // fetch and delete: single use
if !ok {
http.Error(w, "invalid state", http.StatusBadRequest)
return
}
tok, err := h.oauth.Exchange(r.Context(), r.URL.Query().Get("code"),
oauth2.VerifierOption(p.Verifier))
if err != nil {
http.Error(w, "exchange failed", http.StatusUnauthorized)
return
}
rawID, _ := tok.Extra("id_token").(string)
idToken, err := h.verifier.Verify(r.Context(), rawID) // checks signature, iss, aud, exp
if err != nil || idToken.Nonce != p.Nonce {
http.Error(w, "invalid id token", http.StatusUnauthorized)
return
}
var claims struct {
Email string `json:"email"`
}
_ = idToken.Claims(&claims)
h.startSession(w, r, idToken.Subject, claims.Email, tok)
}
h.verifier is created with provider.Verifier(&oidc.Config{ClientID: clientID}) from github.com/coreos/go-oidc/v3/oidc, and oidc.Nonce is an option from that package. Identify users by the (iss, sub) pair, not by email. Emails can change, and can be reused.
Trap follow-up: “Why shouldn’t an SPA hold the access token in localStorage itself?” That question leads straight into the last section.
7. The BFF pattern: keeping tokens out of reach of JS
The interviewer asks: “For an SPA that calls many APIs, how do you design auth to be safe?”
The weak answer: “Get the JWT from the provider, store it in localStorage, attach it to the header.” Every XSS becomes an account takeover, and the long-lived refresh token sits right within JS’s reach.
The strong answer: use a Backend For Frontend (BFF). A small server, same-site as the SPA, acting as the OAuth2 confidential client. The SPA never sees the access or refresh token. The flow goes like this:
- The SPA navigates to the BFF’s
/auth/login; the BFF runs Authorization Code + PKCE (section 6) with the identity provider. - The BFF keeps the access and refresh tokens server-side, tied to a session ID.
- The browser only receives the
__Host-sidcookie (HttpOnly,Secure,SameSite). - The SPA calls
/api/*on the BFF itself; the BFF looks up the session, takes the access token, refreshes it if it has expired, attaches it to the request and proxies it to the real API.
Those four steps, over time, look like this:
sequenceDiagram participant B as Browser (React) participant F as BFF (Go) participant I as Identity Provider participant A as Internal API B->>F: GET /auth/login F->>I: redirect (code_challenge, state, nonce) I->>F: callback ?code=... F->>I: exchange code + code_verifier F-->>B: Set-Cookie __Host-sid (HttpOnly) B->>F: GET /api/orders (cookie) F->>A: Authorization: Bearer access_token A-->>F: data F-->>B: data
func (h *Handler) Proxy(upstream *url.URL) http.Handler {
rp := httputil.NewSingleHostReverseProxy(upstream)
base := rp.Director
rp.Director = func(r *http.Request) {
base(r)
sess := sessionFrom(r.Context()) // middleware already loaded the session from the cookie
r.Header.Del("Cookie") // don't leak the BFF's cookie to the upstream
r.Header.Set("Authorization", "Bearer "+sess.AccessToken)
}
return http.StripPrefix("/api", rp)
}
On the React side, there’s no token logic left at all:
export async function getMe() {
const res = await fetch("/api/me", {
credentials: "include",
headers: { "X-Requested-With": "fetch" },
});
if (res.status === 401) {
window.location.assign("/auth/login"); // let the BFF send the user to log in
return null;
}
return res.json();
}
Because it uses cookies, the BFF must have CSRF protection, meaning the middleware from section 4. In exchange, you eliminate a whole class of risk: the token no longer lives in JS where XSS can steal it. There is a cost. One more component to operate, one more session store, one more hop of latency. For a small system, a traditional session cookie straight from your own backend is enough. A BFF pays off when there are many APIs behind it, or when you have to integrate with an external identity provider.
Trap follow-up: “Does a BFF protect against XSS?” No. It protects against token theft, but malicious script can still call /api/* with the user’s cookie while the page is open. So CSP and sanitizing are still mandatory. The right answer is always “reduce the damage”, never “immune”.
How I grade this part
Three signs that separate people who’ve done it from people who’ve memorized it. One, they start from the threat model: who the attacker is and what they can do, whether that’s running script in the page, luring a user into clicking a link, or intercepting a redirect. Two, they can state the trade-offs of each choice instead of labeling things good or bad. JWT isn’t “bad”, it’s just hard to revoke. localStorage isn’t “forbidden”, it just carries XSS risk. Three, they think in layers. Cookie flags, Origin checks, CSP, sanitizing and rotation can each fail on their own, but together the attacker has to win several times in a row.
An exercise I often suggest: build a small BFF in Go in front of a React SPA, log in through a test OIDC provider with PKCE, then write your own “attack” page to try CSRF against it. Only when you can break it yourself do you understand what each layer is blocking.
Keep reading
Need a developer for a contract or freelance project?
I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.