Gå till innehåll
·26 min läsning

Modern autentisering 2026: JWT, sessioner, OAuth och passkeys

Hela autentiseringslandskapet: när du ska använda sessioner vs JWT, OAuth 2.0/OIDC-flöden, refresh token rotation, passkeys (WebAuthn) och Next.js-autentiseringsmönstren jag faktiskt använder.

Dela:X / TwitterLinkedIn

Autentisering är det enda området inom webbutveckling där "det fungerar" aldrig räcker. En bugg i din datumväljare är irriterande. En bugg i ditt auth-system är ett dataintrång.

Jag har implementerat autentisering från grunden, migrerat mellan leverantörer, felsökt token-stöldincidenter och hanterat konsekvenserna av beslut som "vi fixar säkerheten senare". Det här inlägget är den omfattande guiden jag önskar att jag hade haft när jag började. Inte bara teorin — de faktiska avvägningarna, de verkliga sårbarheterna och mönstren som håller under produktionstryck.

Vi kommer att täcka hela landskapet: sessioner, JWT:er, OAuth 2.0, passkeys, MFA och auktorisering. I slutet kommer du att förstå inte bara hur varje mekanism fungerar, utan när du ska använda den och varför alternativen existerar.

Sessioner vs JWT: De verkliga avvägningarna#

Detta är det första beslutet du ställs inför, och internet är fullt av dåliga råd om det. Låt mig klargöra vad som faktiskt spelar roll.

Sessionsbaserad autentisering#

Sessioner är den ursprungliga metoden. Servern skapar en sessionspost, lagrar den någonstans (databas, Redis, minne) och ger klienten ett ogenomskinligt sessions-ID i en cookie.

typescript
// Förenklat sessionsskapande
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
 
interface Session {
  userId: string;
  createdAt: Date;
  expiresAt: Date;
  ipAddress: string;
  userAgent: string;
}
 
async function createSession(userId: string, request: Request): Promise<string> {
  const sessionId = randomBytes(32).toString("hex");
 
  const session: Session = {
    userId,
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 timmar
    ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
    userAgent: request.headers.get("user-agent") ?? "unknown",
  };
 
  // Lagra i din databas eller Redis
  await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
 
  const cookieStore = await cookies();
  cookieStore.set("session_id", sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 86400,
    path: "/",
  });
 
  return sessionId;
}

Fördelarna är verkliga:

  • Omedelbar återkallelse. Radera sessionsposten och användaren är utloggad. Ingen väntan på utgång. Detta spelar roll när du upptäcker misstänkt aktivitet.
  • Sessionssynlighet. Du kan visa användare deras aktiva sessioner ("inloggad på Chrome, Windows 11, Istanbul") och låta dem återkalla enskilda sessioner.
  • Liten cookiestorlek. Sessions-ID:t är vanligtvis 64 tecken. Cookien växer aldrig.
  • Serversidekontroll. Du kan uppdatera sessionsdata (befordra en användare till admin, ändra behörigheter) och det träder i kraft vid nästa förfrågan.

Nackdelarna är också verkliga:

  • Databasanrop vid varje förfrågan. Varje autentiserad förfrågan behöver en sessionssökning. Med Redis är detta under en millisekund, men det är fortfarande ett beroende.
  • Horisontell skalning kräver delad lagring. Om du har flera servrar behöver de alla åtkomst till samma sessionslagring. Sticky sessions är en bräcklig workaround.
  • CSRF är en fråga. Eftersom cookies skickas automatiskt behöver du CSRF-skydd. SameSite-cookies löser till stor del detta, men du behöver förstå varför.

JWT-baserad autentisering#

JWT:er vänder på modellen. Istället för att lagra sessionstillstånd på servern kodar du in det i en signerad token som klienten håller.

typescript
import { SignJWT, jwtVerify } from "jose";
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
 
async function createAccessToken(userId: string, role: string): Promise<string> {
  return new SignJWT({ sub: userId, role })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .setIssuer("https://akousa.net")
    .setAudience("https://akousa.net")
    .sign(secret);
}
 
async function verifyAccessToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret, {
      issuer: "https://akousa.net",
      audience: "https://akousa.net",
    });
    return payload;
  } catch {
    return null;
  }
}

Fördelarna:

  • Ingen lagring på serversidan. Tokenen är fristående. Du verifierar signaturen och läser anspråken. Inget databasanrop.
  • Fungerar över tjänster. I en mikroservicearkitektur kan vilken tjänst som helst med den publika nyckeln verifiera tokenen. Ingen delad sessionslagring behövs.
  • Tillståndslös skalning. Lägg till fler servrar utan att oroa dig för sessionsaffinitet.

Nackdelarna — och det är dessa folk glossar över:

  • Du kan inte återkalla en JWT. Väl utfärdad är den giltig tills den går ut. Om en användares konto komprometteras kan du inte tvångsutlogga. Du kan bygga en blocklista, men då har du återinfört serversidans tillstånd och förlorat den huvudsakliga fördelen.
  • Tokenstorlek. JWT:er med några anspråk är vanligtvis 800+ bytes. Lägg till roller, behörigheter och metadata och du skickar kilobytes vid varje förfrågan.
  • Payloaden är läsbar. Payloaden är Base64-kodad, inte krypterad. Vem som helst kan avkoda den. Lägg aldrig känslig data i en JWT.
  • Klockavvikelseproblem. Om dina servrar har olika klockor (det händer) blir utgångskontroller opålitliga.

När du ska använda vilken#

Min tumregel:

Använd sessioner när: Du har en monolitisk applikation, du behöver omedelbar återkallelse, du bygger en konsumentprodukt där kontosäkerhet är kritisk, eller dina auth-krav kan ändras ofta.

Använd JWT:er när: Du har en mikroservicearkitektur där tjänster behöver oberoende verifiera identitet, du bygger API-till-API-kommunikation, eller du implementerar ett tredjepartsautentiseringssystem.

I praktiken: De flesta applikationer bör använda sessioner. Argumentet "JWT:er är mer skalbara" gäller bara om du faktiskt har ett skalningsproblem som sessionslagring inte kan lösa — och Redis hanterar miljontals sessionssökningar per sekund. Jag har sett för många projekt välja JWT:er för att de låter modernare, för att sedan bygga en blocklista och ett refresh token-system som är mer komplext än vad sessioner hade varit.

JWT-djupdykning#

Även om du väljer sessionsbaserad auth kommer du att stöta på JWT:er genom OAuth, OIDC och tredjepartsintegrationer. Att förstå internerna är inte förhandlingsbart.

Anatomin hos en JWT#

En JWT har tre delar separerade av punkter: header.payload.signature

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...

Header — deklarerar algoritmen och tokentypen:

json
{
  "alg": "RS256",
  "typ": "JWT"
}

Payload — innehåller anspråk. Standardanspråk har korta namn:

json
{
  "sub": "user_123",       // Subject (vem handlar detta om)
  "iss": "https://auth.example.com",  // Issuer (vem skapade detta)
  "aud": "https://api.example.com",   // Audience (vem bör acceptera detta)
  "iat": 1709312000,       // Issued At (Unix-tidsstämpel)
  "exp": 1709312900,       // Expiration (Unix-tidsstämpel)
  "role": "admin"          // Anpassat anspråk
}

Signatur — bevisar att tokenen inte har manipulerats. Skapas genom att signera den kodade headern och payloaden med en hemlig nyckel.

RS256 vs HS256: Detta spelar faktiskt roll#

HS256 (HMAC-SHA256) — symmetrisk. Samma hemlighet signerar och verifierar. Enkelt, men varje tjänst som behöver verifiera tokens måste ha hemligheten. Om någon av dem komprometteras kan en angripare förfalska tokens.

RS256 (RSA-SHA256) — asymmetrisk. En privat nyckel signerar, en publik nyckel verifierar. Bara auth-servern behöver den privata nyckeln. Vilken tjänst som helst kan verifiera med den publika nyckeln. Om en verifieringstjänst komprometteras kan angriparen läsa tokens men inte förfalska dem.

typescript
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
 
// RS256 — använd detta när flera tjänster verifierar tokens
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
 
async function signWithRS256(payload: Record<string, unknown>) {
  const privateKey = await importPKCS8(privateKeyPem, "RS256");
 
  return new SignJWT(payload)
    .setProtectedHeader({ alg: "RS256", typ: "JWT" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .sign(privateKey);
}
 
async function verifyWithRS256(token: string) {
  const publicKey = await importSPKI(publicKeyPem, "RS256");
 
  const { payload } = await jwtVerify(token, publicKey, {
    algorithms: ["RS256"], // KRITISKT: begränsa alltid algoritmer
  });
 
  return payload;
}

Regel: Använd RS256 när tokens korsar tjänstgränser. Använd HS256 bara när samma tjänst både signerar och verifierar.

alg: none-attacken#

Detta är den mest kända JWT-sårbarheten, och den är pinsamt enkel. Vissa JWT-bibliotek brukade:

  1. Läsa alg-fältet från headern
  2. Använda vilken algoritm den specificerade
  3. Om alg: "none", hoppa över signaturverifiering helt

En angripare kunde ta en giltig JWT, ändra payloaden (t.ex. sätta "role": "admin"), sätta alg till "none", ta bort signaturen och skicka den. Servern skulle acceptera den.

typescript
// SÅRBAR — gör aldrig detta
function verifyJwt(token: string) {
  const [headerB64, payloadB64, signature] = token.split(".");
  const header = JSON.parse(atob(headerB64));
 
  if (header.alg === "none") {
    // "Ingen signatur behövs" — KATASTROFALT
    return JSON.parse(atob(payloadB64));
  }
 
  // ... verifiera signatur
}

Lösningen är enkel: ange alltid den förväntade algoritmen uttryckligen. Låt aldrig tokenen berätta hur den ska verifieras.

typescript
// SÄKER — algoritmen är hårdkodad, inte läst från tokenen
const { payload } = await jwtVerify(token, key, {
  algorithms: ["RS256"], // Acceptera bara RS256 — ignorera headern
});

Moderna bibliotek som jose hanterar detta korrekt som standard, men du bör fortfarande uttryckligen ange algorithms-alternativet som försvar på djupet.

Algoritmförväxlingsattack#

Relaterat till ovanstående: om en server är konfigurerad att acceptera RS256 kan en angripare:

  1. Hämta serverns publika nyckel (den är publik, trots allt)
  2. Skapa en token med alg: "HS256"
  3. Signera den med den publika nyckeln som HMAC-hemlighet

Om servern läser alg-headern och byter till HS256-verifiering blir den publika nyckeln (som alla känner till) den delade hemligheten. Signaturen är giltig. Angriparen har förfalskat en token.

Igen, lösningen är densamma: lita aldrig på algoritmen från tokenheadern. Hårdkoda den alltid.

Refresh Token Rotation#

Om du använder JWT:er behöver du en refresh token-strategi. Att skicka en långlivad åtkomsttoken är att be om problem — om den stjäls har angriparen åtkomst under hela livstiden.

Mönstret:

  • Åtkomsttoken: kortlivad (15 minuter). Används för API-förfrågningar.
  • Refresh token: långlivad (30 dagar). Används bara för att hämta en ny åtkomsttoken.
typescript
import { randomBytes } from "crypto";
 
interface RefreshTokenRecord {
  tokenHash: string;
  userId: string;
  familyId: string;  // Grupperar relaterade tokens tillsammans
  used: boolean;
  expiresAt: Date;
  createdAt: Date;
}
 
async function issueTokenPair(userId: string) {
  const familyId = randomBytes(16).toString("hex");
 
  const accessToken = await createAccessToken(userId);
  const refreshToken = randomBytes(64).toString("hex");
  const refreshTokenHash = await hashToken(refreshToken);
 
  // Lagra refresh token-post
  await db.refreshToken.create({
    data: {
      tokenHash: refreshTokenHash,
      userId,
      familyId,
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  return { accessToken, refreshToken };
}

Rotation vid varje användning#

Varje gång klienten använder en refresh token för att hämta en ny åtkomsttoken utfärdar du en ny refresh token och ogiltigförklarar den gamla:

typescript
async function rotateTokens(incomingRefreshToken: string) {
  const tokenHash = await hashToken(incomingRefreshToken);
  const record = await db.refreshToken.findUnique({
    where: { tokenHash },
  });
 
  if (!record) {
    // Token finns inte — möjlig stöld
    return null;
  }
 
  if (record.expiresAt < new Date()) {
    // Token utgången
    await db.refreshToken.delete({ where: { tokenHash } });
    return null;
  }
 
  if (record.used) {
    // DENNA TOKEN HAR REDAN ANVÄNTS.
    // Någon spelar upp den — antingen den legitima användaren
    // eller en angripare. Hur som helst, döda hela familjen.
    await db.refreshToken.deleteMany({
      where: { familyId: record.familyId },
    });
 
    console.error(
      `Refresh token-återanvändning upptäckt för användare ${record.userId}, familj ${record.familyId}. Alla tokens i familjen ogiltigförklarade.`
    );
 
    return null;
  }
 
  // Markera aktuell token som använd (radera inte — vi behöver den för återanvändningsdetektering)
  await db.refreshToken.update({
    where: { tokenHash },
    data: { used: true },
  });
 
  // Utfärda nytt par med samma familje-ID
  const newRefreshToken = randomBytes(64).toString("hex");
  const newRefreshTokenHash = await hashToken(newRefreshToken);
 
  await db.refreshToken.create({
    data: {
      tokenHash: newRefreshTokenHash,
      userId: record.userId,
      familyId: record.familyId,  // Samma familj
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  const newAccessToken = await createAccessToken(record.userId);
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Varför familjeogiltigförklaring spelar roll#

Tänk på detta scenario:

  1. Användaren loggar in, får refresh token A
  2. Angriparen stjäl refresh token A
  3. Angriparen använder A för att hämta ett nytt par (åtkomsttoken + refresh token B)
  4. Användaren försöker använda A (som de fortfarande har) för att uppdatera

Utan återanvändningsdetektering får användaren bara ett fel. Angriparen fortsätter med token B. Användaren loggar in igen och vet aldrig att deras konto var komprometterat.

Med återanvändningsdetektering och familjeogiltigförklaring: när användaren försöker använda den redan använda token A upptäcker systemet återanvändning, ogiltigförklarar varje token i familjen (inklusive B) och tvingar både användaren och angriparen att autentisera om. Användaren får en "vänligen logga in igen"-uppmaning och kanske inser att något är fel.

Det här är metoden som används av Auth0, Okta och Auth.js. Den är inte perfekt — om angriparen använder tokenen före den legitima användaren blir den legitima användaren den som utlöser återanvändningsvarningen. Men det är det bästa vi kan göra med bärartoken.

OAuth 2.0 & OIDC#

OAuth 2.0 och OpenID Connect är protokollen bakom "Logga in med Google/GitHub/Apple." Att förstå dem är viktigt även om du använder ett bibliotek, för när saker går sönder — och det kommer de att göra — behöver du veta vad som händer på protokollnivå.

Den avgörande skillnaden#

OAuth 2.0 är ett auktoriserings-protokoll. Det besvarar: "Får den här applikationen komma åt den här användarens data?" Resultatet är en åtkomsttoken som beviljar specifika behörigheter (scopes).

OpenID Connect (OIDC) är ett autentiserings-lager byggt ovanpå OAuth 2.0. Det besvarar: "Vem är den här användaren?" Resultatet är en ID-token (en JWT) som innehåller användaridentitetsinformation.

När du "Loggar in med Google" använder du OIDC. Google berättar för din app vem användaren är (autentisering). Du kan också begära OAuth-scopes för att komma åt deras kalender eller drive (auktorisering).

Authorization Code Flow med PKCE#

Detta är flödet du bör använda för webbapplikationer. PKCE (Proof Key for Code Exchange) designades ursprungligen för mobilappar men rekommenderas nu för alla klienter, inklusive applikationer på serversidan.

typescript
import { randomBytes, createHash } from "crypto";
 
// Steg 1: Generera PKCE-värden och omdirigera användaren
function initiateOAuthFlow() {
  // Code verifier: slumpmässig 43-128 teckensträng
  const codeVerifier = randomBytes(32)
    .toString("base64url")
    .slice(0, 43);
 
  // Code challenge: SHA256-hash av verifieraren, base64url-kodad
  const codeChallenge = createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");
 
  // State: slumpmässigt värde för CSRF-skydd
  const state = randomBytes(16).toString("hex");
 
  // Lagra båda i sessionen (serversidan!) innan omdirigering
  // Lägg ALDRIG code_verifier i en cookie eller URL-parameter
  session.codeVerifier = codeVerifier;
  session.oauthState = state;
 
  const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
  authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
  authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("scope", "openid email profile");
  authUrl.searchParams.set("state", state);
  authUrl.searchParams.set("code_challenge", codeChallenge);
  authUrl.searchParams.set("code_challenge_method", "S256");
 
  return authUrl.toString();
}
typescript
// Steg 2: Hantera callbacken
async function handleOAuthCallback(request: Request) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  const error = url.searchParams.get("error");
 
  // Kontrollera efter fel från leverantören
  if (error) {
    throw new Error(`OAuth error: ${error}`);
  }
 
  // Verifiera att state matchar (CSRF-skydd)
  if (state !== session.oauthState) {
    throw new Error("State mismatch — möjlig CSRF-attack");
  }
 
  // Byt auktoriseringskoden mot tokens
  const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: code!,
      redirect_uri: "https://example.com/api/auth/callback/google",
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      code_verifier: session.codeVerifier, // PKCE: bevisar att vi startade detta flöde
    }),
  });
 
  const tokens = await tokenResponse.json();
  // tokens.access_token — för API-anrop till Google
  // tokens.id_token — JWT med användaridentitet (OIDC)
  // tokens.refresh_token — för att hämta nya åtkomsttokens
 
  // Steg 3: Verifiera ID-tokenen och extrahera användarinfo
  const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
 
  return {
    googleId: idTokenPayload.sub,
    email: idTokenPayload.email,
    name: idTokenPayload.name,
    picture: idTokenPayload.picture,
  };
}

De tre endpointarna#

Varje OAuth/OIDC-leverantör exponerar dessa:

  1. Auktoriseringsendpoint — dit du omdirigerar användaren för att logga in och bevilja behörigheter. Returnerar en auktoriseringskod.
  2. Tokenendpoint — dit din server byter auktoriseringskoden mot åtkomst-/refresh-/ID-tokens. Detta är ett server-till-server-anrop.
  3. UserInfo-endpoint — dit du kan hämta ytterligare användarprofildata med åtkomsttoken. Med OIDC finns mycket av detta redan i ID-tokenen.

State-parametern#

state-parametern förhindrar CSRF-attacker på OAuth-callbacken. Utan den:

  1. Angriparen startar ett OAuth-flöde på sin egen maskin, får en auktoriseringskod
  2. Angriparen skapar en URL: https://dinapp.com/callback?code=ANGRIPARENS_KOD
  3. Angriparen lurar ett offer att klicka på den (e-postlänk, dold bild)
  4. Din app byter angriparens kod och kopplar angriparens Google-konto till offrets session

Med state: din app genererar ett slumpmässigt värde, lagrar det i sessionen och inkluderar det i auktoriserings-URL:en. När callbacken kommer verifierar du att state matchar. Angriparen kan inte förfalska detta eftersom de inte har åtkomst till offrets session.

Auth.js (NextAuth) med Next.js App Router#

Auth.js är det jag tar till först i de flesta Next.js-projekt. Det hanterar OAuth-dansen, sessionshantering, databaspersistens och CSRF-skydd. Här är en produktionsfärdig uppsättning.

Grundkonfiguration#

typescript
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
 
  // Använd databassessioner (inte JWT) för bättre säkerhet
  session: {
    strategy: "database",
    maxAge: 30 * 24 * 60 * 60, // 30 dagar
    updateAge: 24 * 60 * 60,   // Förläng session var 24:e timme
  },
 
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      // Begär specifika scopes
      authorization: {
        params: {
          scope: "openid email profile",
          prompt: "consent",
          access_type: "offline", // Hämta refresh token
        },
      },
    }),
 
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
 
    // E-post/lösenordsinloggning (använd försiktigt)
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials) => {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }
 
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });
 
        if (!user || !user.passwordHash) {
          return null;
        }
 
        const isValid = await verifyPassword(
          credentials.password as string,
          user.passwordHash
        );
 
        if (!isValid) {
          return null;
        }
 
        return {
          id: user.id,
          email: user.email,
          name: user.name,
          image: user.image,
        };
      },
    }),
  ],
 
  callbacks: {
    // Kontrollera vem som kan logga in
    async signIn({ user, account }) {
      // Blockera inloggning för bannade användare
      if (user.id) {
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { banned: true },
        });
        if (dbUser?.banned) return false;
      }
      return true;
    },
 
    // Lägg till anpassade fält i sessionen
    async session({ session, user }) {
      if (session.user) {
        session.user.id = user.id;
        // Hämta roll från databasen
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { role: true },
        });
        session.user.role = dbUser?.role ?? "user";
      }
      return session;
    },
  },
 
  pages: {
    signIn: "/login",
    error: "/auth/error",
    verifyRequest: "/auth/verify",
  },
});

Route Handler#

typescript
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
 
export const { GET, POST } = handlers;

Middlewareskydd#

typescript
// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
 
export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAuthPage = req.nextUrl.pathname.startsWith("/login")
    || req.nextUrl.pathname.startsWith("/register");
  const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard")
    || req.nextUrl.pathname.startsWith("/settings")
    || req.nextUrl.pathname.startsWith("/admin");
  const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
 
  // Omdirigera inloggade användare från auth-sidor
  if (isLoggedIn && isAuthPage) {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  // Omdirigera oautentiserade användare till inloggning
  if (!isLoggedIn && isProtectedRoute) {
    const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
    return NextResponse.redirect(
      new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl)
    );
  }
 
  // Kontrollera adminroll
  if (isAdminRoute && req.auth?.user?.role !== "admin") {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  return NextResponse.next();
});
 
export const config = {
  matcher: [
    "/dashboard/:path*",
    "/settings/:path*",
    "/admin/:path*",
    "/login",
    "/register",
  ],
};

Använda sessionen i serverkomponenter#

typescript
// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
 
export default async function DashboardPage() {
  const session = await auth();
 
  if (!session?.user) {
    redirect("/login");
  }
 
  return (
    <div>
      <h1>Välkommen, {session.user.name}</h1>
      <p>Roll: {session.user.role}</p>
    </div>
  );
}

Använda sessionen i klientkomponenter#

typescript
"use client";
 
import { useSession } from "next-auth/react";
 
export function UserMenu() {
  const { data: session, status } = useSession();
 
  if (status === "loading") {
    return <div>Laddar...</div>;
  }
 
  if (status === "unauthenticated") {
    return <a href="/login">Logga in</a>;
  }
 
  return (
    <div>
      <img
        src={session?.user?.image ?? "/default-avatar.png"}
        alt={session?.user?.name ?? "Användare"}
      />
      <span>{session?.user?.name}</span>
    </div>
  );
}

Passkeys (WebAuthn)#

Passkeys är den mest betydande autentiseringsförbättringen på flera år. De är nätfiskeresistenta, replay-resistenta och eliminerar hela kategorin av lösenordsrelaterade sårbarheter. Om du startar ett nytt projekt 2026 bör du stödja passkeys.

Hur passkeys fungerar#

Passkeys använder kryptering med publika nycklar, stödd av biometri eller enhets-PIN-koder:

  1. Registrering: Webbläsaren genererar ett nyckelpar. Den privata nyckeln stannar på enheten (i en säker enklave, skyddad av biometri). Den publika nyckeln skickas till din server.
  2. Autentisering: Servern skickar en utmaning (slumpmässiga bytes). Enheten signerar utmaningen med den privata nyckeln (efter biometrisk verifiering). Servern verifierar signaturen med den lagrade publika nyckeln.

Ingen delad hemlighet passerar någonsin nätverket. Det finns inget att nätfiska, inget att läcka, inget att stoppa.

Varför passkeys är nätfiskeresistenta#

När en passkey skapas binds den till ursprunget (t.ex. https://example.com). Webbläsaren kommer bara att använda passkeyn på exakt det ursprung den skapades för. Om en angripare skapar en liknande webbplats på https://exarnple.com kommer passkeyn helt enkelt inte att erbjudas. Detta upprätthålls av webbläsaren, inte av användarvaksamhet.

Detta är fundamentalt annorlunda från lösenord, där användare rutinmässigt skriver in sina uppgifter på nätfiskesajter för att sidan ser rätt ut.

Implementation med SimpleWebAuthn#

SimpleWebAuthn är biblioteket jag rekommenderar. Det hanterar WebAuthn-protokollet korrekt och har bra TypeScript-typer.

typescript
// Serversidan: Registrering
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
  GenerateRegistrationOptionsOpts,
  VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
 
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
 
async function startRegistration(userId: string, userEmail: string) {
  // Hämta användarens befintliga passkeys för att exkludera dem
  const existingCredentials = await db.credential.findMany({
    where: { userId },
    select: { credentialId: true, transports: true },
  });
 
  const options: GenerateRegistrationOptionsOpts = {
    rpName,
    rpID,
    userID: new TextEncoder().encode(userId),
    userName: userEmail,
    attestationType: "none", // Vi behöver inte attestation för de flesta appar
    excludeCredentials: existingCredentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    })),
    authenticatorSelection: {
      residentKey: "preferred",
      userVerification: "preferred",
    },
  };
 
  const registrationOptions = await generateRegistrationOptions(options);
 
  // Lagra utmaningen tillfälligt — vi behöver den för verifiering
  await redis.set(
    `webauthn:challenge:${userId}`,
    registrationOptions.challenge,
    "EX",
    300 // 5 minuters utgång
  );
 
  return registrationOptions;
}
 
async function finishRegistration(userId: string, response: unknown) {
  const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
 
  if (!expectedChallenge) {
    throw new Error("Challenge expired or not found");
  }
 
  let verification: VerifiedRegistrationResponse;
  try {
    verification = await verifyRegistrationResponse({
      response: response as any,
      expectedChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
    });
  } catch (error) {
    throw new Error(`Registration verification failed: ${error}`);
  }
 
  if (!verification.verified || !verification.registrationInfo) {
    throw new Error("Registration verification failed");
  }
 
  const { credential } = verification.registrationInfo;
 
  // Lagra uppgifterna i databasen
  await db.credential.create({
    data: {
      userId,
      credentialId: credential.id,
      publicKey: Buffer.from(credential.publicKey),
      counter: credential.counter,
      transports: credential.transports ?? [],
    },
  });
 
  // Rensa upp
  await redis.del(`webauthn:challenge:${userId}`);
 
  return { verified: true };
}
typescript
// Serversidan: Autentisering
import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";
 
async function startAuthentication(userId?: string) {
  let allowCredentials;
 
  // Om vi vet vem användaren är (t.ex. de angav sin e-post), begränsa till deras passkeys
  if (userId) {
    const credentials = await db.credential.findMany({
      where: { userId },
      select: { credentialId: true, transports: true },
    });
    allowCredentials = credentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    }));
  }
 
  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials,
    userVerification: "preferred",
  });
 
  // Lagra utmaning för verifiering
  const challengeKey = userId
    ? `webauthn:auth:${userId}`
    : `webauthn:auth:${options.challenge}`;
 
  await redis.set(challengeKey, options.challenge, "EX", 300);
 
  return options;
}
 
async function finishAuthentication(
  response: any,
  expectedChallenge: string,
  userId: string
) {
  const credential = await db.credential.findUnique({
    where: { credentialId: response.id },
  });
 
  if (!credential) {
    throw new Error("Credential not found");
  }
 
  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: credential.credentialId,
      publicKey: credential.publicKey,
      counter: credential.counter,
      transports: credential.transports,
    },
  });
 
  if (!verification.verified) {
    throw new Error("Authentication verification failed");
  }
 
  // VIKTIGT: Uppdatera räknaren för att förhindra replay-attacker
  await db.credential.update({
    where: { credentialId: response.id },
    data: {
      counter: verification.authenticationInfo.newCounter,
    },
  });
 
  return { verified: true, userId: credential.userId };
}
typescript
// Klientsidan: Registrering
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
 
async function registerPasskey() {
  // Hämta alternativ från din server
  const optionsResponse = await fetch("/api/auth/webauthn/register", {
    method: "POST",
  });
  const options = await optionsResponse.json();
 
  try {
    // Detta utlöser webbläsarens passkey-UI (biometrisk uppmaning)
    const credential = await webAuthnRegister(options);
 
    // Skicka uppgifterna till din server för verifiering
    const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(credential),
    });
 
    const result = await verifyResponse.json();
    if (result.verified) {
      console.log("Passkey registrerad framgångsrikt!");
    }
  } catch (error) {
    if ((error as Error).name === "NotAllowedError") {
      console.log("Användaren avbröt passkey-registreringen");
    }
  }
}

Attestation vs Assertion#

Två termer du kommer att stöta på:

  • Attestation (registrering): Processen att skapa en ny uppgift. Autentiseraren "attesterar" sin identitet och sina förmågor. För de flesta applikationer behöver du inte verifiera attestation — sätt attestationType: "none".
  • Assertion (autentisering): Processen att använda en befintlig uppgift för att signera en utmaning. Autentiseraren "hävdar" att användaren är den de påstår sig vara.

MFA-implementation#

Även med passkeys kommer du att stöta på scenarier där MFA via TOTP behövs — passkeys som en andra faktor tillsammans med lösenord, eller stöd för användare vars enheter inte stöder passkeys.

TOTP (Tidsbaserade engångslösenord)#

TOTP är protokollet bakom Google Authenticator, Authy och 1Password. Det fungerar genom att:

  1. Servern genererar en slumpmässig hemlighet (base32-kodad)
  2. Användaren skannar en QR-kod som innehåller hemligheten
  3. Både server och autentiseringsapp beräknar samma 6-siffriga kod från hemligheten och aktuell tid
  4. Koder ändras var 30:e sekund
typescript
import { createHmac, randomBytes } from "crypto";
 
// Generera en TOTP-hemlighet för en användare
function generateTOTPSecret(): string {
  const buffer = randomBytes(20);
  return base32Encode(buffer);
}
 
function base32Encode(buffer: Buffer): string {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  let result = "";
  let bits = 0;
  let value = 0;
 
  for (const byte of buffer) {
    value = (value << 8) | byte;
    bits += 8;
    while (bits >= 5) {
      result += alphabet[(value >>> (bits - 5)) & 0x1f];
      bits -= 5;
    }
  }
 
  if (bits > 0) {
    result += alphabet[(value << (5 - bits)) & 0x1f];
  }
 
  return result;
}
 
// Generera TOTP-URI för QR-kod
function generateTOTPUri(
  secret: string,
  userEmail: string,
  issuer: string = "akousa.net"
): string {
  const encodedIssuer = encodeURIComponent(issuer);
  const encodedEmail = encodeURIComponent(userEmail);
  return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}
typescript
// Verifiera en TOTP-kod
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
  const secretBuffer = base32Decode(secret);
  const now = Math.floor(Date.now() / 1000);
 
  // Kontrollera aktuellt tidssteg och angränsande (tolerans för klockavvikelse)
  for (let i = -window; i <= window; i++) {
    const timeStep = Math.floor(now / 30) + i;
    const expectedCode = generateTOTPCode(secretBuffer, timeStep);
 
    // Konstanttidsjämförelse för att förhindra timing-attacker
    if (timingSafeEqual(code, expectedCode)) {
      return true;
    }
  }
 
  return false;
}
 
function generateTOTPCode(secret: Buffer, timeStep: number): string {
  // Konvertera tidssteg till 8-byte big-endian buffer
  const timeBuffer = Buffer.alloc(8);
  timeBuffer.writeBigInt64BE(BigInt(timeStep));
 
  // HMAC-SHA1
  const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
 
  // Dynamisk trunkering
  const offset = hmac[hmac.length - 1] & 0x0f;
  const code =
    ((hmac[offset] & 0x7f) << 24) |
    ((hmac[offset + 1] & 0xff) << 16) |
    ((hmac[offset + 2] & 0xff) << 8) |
    (hmac[offset + 3] & 0xff);
 
  return (code % 1_000_000).toString().padStart(6, "0");
}
 
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  const bufA = Buffer.from(a);
  const bufB = Buffer.from(b);
  return createHmac("sha256", "key").update(bufA).digest()
    .equals(createHmac("sha256", "key").update(bufB).digest());
}

Säkerhetskoder#

Användare tappar bort sina telefoner. Generera alltid säkerhetskoder under MFA-uppsättningen:

typescript
import { randomBytes, createHash } from "crypto";
 
function generateBackupCodes(count: number = 10): string[] {
  return Array.from({ length: count }, () =>
    randomBytes(4).toString("hex").toUpperCase() // 8-teckens hexkoder
  );
}
 
async function storeBackupCodes(userId: string, codes: string[]) {
  // Hasha koderna innan lagring — behandla dem som lösenord
  const hashedCodes = codes.map((code) =>
    createHash("sha256").update(code).digest("hex")
  );
 
  await db.backupCode.createMany({
    data: hashedCodes.map((hash) => ({
      userId,
      codeHash: hash,
      used: false,
    })),
  });
 
  // Returnera klartextkoderna EN gång för att användaren ska spara
  // Efter detta har vi bara hasharna
  return codes;
}
 
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
  const codeHash = createHash("sha256")
    .update(code.toUpperCase().replace(/\s/g, ""))
    .digest("hex");
 
  const backupCode = await db.backupCode.findFirst({
    where: {
      userId,
      codeHash,
      used: false,
    },
  });
 
  if (!backupCode) return false;
 
  // Markera som använd — varje säkerhetskod fungerar exakt en gång
  await db.backupCode.update({
    where: { id: backupCode.id },
    data: { used: true, usedAt: new Date() },
  });
 
  return true;
}

Återställningsflöde#

MFA-återställning är den del de flesta guider hoppar över och de flesta verkliga applikationer misslyckas med. Här är vad jag implementerar:

  1. Primärt: TOTP-kod från autentiseringsapp
  2. Sekundärt: En av de 10 säkerhetskoderna
  3. Sista utväg: E-postbaserad återställning med 24 timmars vänteperiod och avisering till användarens andra verifierade kanaler

Vänteperioden är kritisk. Om en angripare har komprometterat användarens e-post vill du inte låta dem inaktivera MFA omedelbart. 24-timmarsförseningen ger den legitima användaren tid att uppmärksamma e-posten och ingripa.

typescript
async function initiateAccountRecovery(email: string) {
  const user = await db.user.findUnique({ where: { email } });
  if (!user) {
    // Avslöja inte om kontot existerar
    return { message: "If that email exists, we've sent recovery instructions." };
  }
 
  const recoveryToken = randomBytes(32).toString("hex");
  const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
 
  await db.recoveryRequest.create({
    data: {
      userId: user.id,
      tokenHash,
      expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 timmar
      status: "pending",
    },
  });
 
  // Skicka e-post med återställningslänk
  await sendEmail(email, {
    subject: "Account Recovery Request",
    body: `
      En begäran gjordes att inaktivera MFA på ditt konto.
      Om det var du, klicka på länken nedan efter 24 timmar: ...
      Om det INTE var du, vänligen ändra ditt lösenord omedelbart.
    `,
  });
 
  return { message: "If that email exists, we've sent recovery instructions." };
}

Auktoriseringsmönster#

Autentisering berättar vem någon är. Auktorisering berättar vad de får göra. Att göra fel här är hur du hamnar i nyheterna.

RBAC vs ABAC#

RBAC (Rollbaserad åtkomstkontroll): Användare har roller, roller har behörigheter. Enkelt, lätt att resonera om, fungerar för de flesta applikationer.

typescript
// RBAC — okomplicerade rollkontroller
type Role = "user" | "editor" | "admin" | "super_admin";
 
const ROLE_PERMISSIONS: Record<Role, string[]> = {
  user: ["read:own_profile", "update:own_profile", "read:posts"],
  editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
  admin: [
    "read:own_profile", "update:own_profile",
    "read:posts", "create:posts", "update:posts", "delete:posts",
    "read:users", "update:users",
  ],
  super_admin: ["*"], // Var försiktig med wildcards
};
 
function hasPermission(role: Role, permission: string): boolean {
  const permissions = ROLE_PERMISSIONS[role];
  return permissions.includes("*") || permissions.includes(permission);
}
 
// Användning i en API-route
export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  if (!hasPermission(session.user.role as Role, "delete:posts")) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }
 
  const { id } = await params;
  await db.post.delete({ where: { id } });
  return Response.json({ success: true });
}

ABAC (Attributbaserad åtkomstkontroll): Behörigheter beror på attribut hos användaren, resursen och kontexten. Mer flexibelt men mer komplext.

typescript
// ABAC — när RBAC inte räcker
interface PolicyContext {
  user: {
    id: string;
    role: string;
    department: string;
    clearanceLevel: number;
  };
  resource: {
    type: string;
    ownerId: string;
    classification: string;
    department: string;
  };
  action: string;
  environment: {
    ipAddress: string;
    time: Date;
    mfaVerified: boolean;
  };
}
 
function evaluatePolicy(context: PolicyContext): boolean {
  const { user, resource, action, environment } = context;
 
  // Användare kan alltid läsa sina egna resurser
  if (action === "read" && resource.ownerId === user.id) {
    return true;
  }
 
  // Administratörer kan läsa vilken resurs som helst i sin avdelning
  if (
    action === "read" &&
    user.role === "admin" &&
    user.department === resource.department
  ) {
    return true;
  }
 
  // Klassificerade resurser kräver MFA och lägsta behörighetsnivå
  if (resource.classification === "confidential") {
    if (!environment.mfaVerified) return false;
    if (user.clearanceLevel < 3) return false;
  }
 
  // Destruktiva åtgärder blockerade utanför kontorstid
  if (action === "delete") {
    const hour = environment.time.getHours();
    if (hour < 9 || hour > 17) return false;
  }
 
  return false; // Neka som standard
}

Regeln "Kontrollera vid gränsen"#

Detta är den enskilt viktigaste auktoriseringsprincipen: kontrollera behörigheter vid varje förtroendegräns, inte bara på UI-nivå.

typescript
// DÅLIGT — kontrollerar bara i komponenten
function DeleteButton({ post }: { post: Post }) {
  const { data: session } = useSession();
 
  // Detta döljer knappen, men förhindrar inte radering
  if (session?.user?.role !== "admin") return null;
 
  return <button onClick={() => deletePost(post.id)}>Radera</button>;
}
 
// OCKSÅ DÅLIGT — kontrollerar i en server action men inte API-routen
async function deletePostAction(postId: string) {
  const session = await auth();
  if (session?.user?.role !== "admin") throw new Error("Forbidden");
  await db.post.delete({ where: { id: postId } });
}
// En angripare kan fortfarande anropa POST /api/posts/123 direkt
 
// BRA — kontrollera vid varje gräns
// 1. Dölj knappen i UI:t (UX, inte säkerhet)
// 2. Kontrollera i server action (försvar på djupet)
// 3. Kontrollera i API-routen (den faktiska säkerhetsgränsen)
// 4. Valfritt, kontrollera i middleware (för ruttskyddsnivå)

UI-kontrollen är för användarupplevelsen. Serverkontrollen är för säkerheten. Förlita dig aldrig på bara en av dem.

Behörighetskontroller i Next.js Middleware#

Middleware körs före varje matchad förfrågan. Det är en bra plats för grovkornig åtkomstkontroll:

typescript
// "Har den här användaren tillåtelse att komma åt den här sektionen överhuvudtaget?"
// Finkorniga kontroller ("Får den här användaren redigera DETTA inlägg?") hör hemma i route-handlern
// eftersom middleware inte har enkel åtkomst till request body eller route params.
 
export default auth((req) => {
  const path = req.nextUrl.pathname;
  const role = req.auth?.user?.role;
 
  // Ruttnivå-åtkomstkontroll
  const routeAccess: Record<string, Role[]> = {
    "/admin": ["admin", "super_admin"],
    "/editor": ["editor", "admin", "super_admin"],
    "/dashboard": ["user", "editor", "admin", "super_admin"],
  };
 
  for (const [route, allowedRoles] of Object.entries(routeAccess)) {
    if (path.startsWith(route)) {
      if (!role || !allowedRoles.includes(role as Role)) {
        return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
      }
    }
  }
 
  return NextResponse.next();
});

Vanliga sårbarheter#

Det här är attackerna jag ser oftast i verkliga kodbaser. Att förstå dem är väsentligt.

Sessionsfixering#

Attacken: En angripare skapar en giltig session på din webbplats och lurar sedan ett offer att använda det sessions-ID:t (t.ex. via en URL-parameter eller genom att sätta en cookie via en subdomän). När offret loggar in har angriparens session nu en autentiserad användare.

Lösningen: Regenerera alltid sessions-ID:t efter lyckad autentisering. Låt aldrig ett sessions-ID före autentisering överföras till en session efter autentisering.

typescript
async function login(credentials: { email: string; password: string }, request: Request) {
  const user = await verifyCredentials(credentials);
  if (!user) throw new Error("Invalid credentials");
 
  // KRITISKT: Radera den gamla sessionen och skapa en ny
  const oldSessionId = getSessionIdFromCookie(request);
  if (oldSessionId) {
    await redis.del(`session:${oldSessionId}`);
  }
 
  // Skapa en helt ny session med ett nytt ID
  const newSessionId = await createSession(user.id, request);
  return newSessionId;
}

CSRF (Cross-Site Request Forgery)#

Attacken: En användare är inloggad på din webbplats. De besöker en skadlig sida som gör en förfrågan till din webbplats. Eftersom cookies skickas automatiskt är förfrågan autentiserad.

Den moderna lösningen: SameSite-cookies. Att ställa in SameSite: Lax (standard i de flesta webbläsare nu) förhindrar att cookies skickas vid cross-origin POST-förfrågningar, vilket täcker de flesta CSRF-scenarier.

typescript
// SameSite=Lax täcker de flesta CSRF-scenarier:
// - Blockerar cookies vid cross-origin POST, PUT, DELETE
// - Tillåter cookies vid cross-origin GET (toppnivånavigering)
//   Detta är okej eftersom GET-förfrågningar inte bör ha sidoeffekter
 
cookieStore.set("session_id", sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",  // Detta är ditt CSRF-skydd
  maxAge: 86400,
  path: "/",
});

För API:er som accepterar JSON får du ytterligare skydd gratis: headern Content-Type: application/json kan inte sättas av HTML-formulär, och CORS förhindrar JavaScript på andra ursprung från att göra förfrågningar med anpassade headers.

Om du behöver starkare garantier (t.ex. du accepterar formulärinlämningar), använd double-submit cookie-mönstret eller en synkroniseringstoken. Auth.js hanterar detta åt dig.

Öppna omdirigeringar i OAuth#

Attacken: En angripare skapar en OAuth-callback-URL som omdirigerar till deras webbplats efter autentisering: https://dinapp.com/callback?redirect_to=https://evil.com/steal-token

Om din callback-handler blint omdirigerar till redirect_to-parametern hamnar användaren på angriparens webbplats, potentiellt med tokens i URL:en.

typescript
// SÅRBAR
async function handleCallback(request: Request) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect_to") ?? "/";
  // ... autentisera användaren ...
  return Response.redirect(redirectTo); // Kan vara https://evil.com!
}
 
// SÄKER
async function handleCallback(request: Request) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect_to") ?? "/";
 
  // Validera omdirigeringsadressen
  const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
  // ... autentisera användaren ...
  return Response.redirect(safeRedirect);
}
 
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
  try {
    const url = new URL(redirect, baseUrl);
    const base = new URL(baseUrl);
 
    // Tillåt bara omdirigeringar till samma ursprung
    if (url.origin !== base.origin) {
      return "/";
    }
 
    // Tillåt bara sökvägsomdirigeringar (inga javascript: eller data: URI:er)
    if (!url.pathname.startsWith("/")) {
      return "/";
    }
 
    return url.pathname + url.search;
  } catch {
    return "/";
  }
}

Tokenläckage via Referrer#

Om du lägger tokens i URL:er (gör inte det) kommer de att läcka genom Referer-headern när användare klickar på länkar. Detta har orsakat verkliga intrång, inklusive hos GitHub.

Regler:

  • Lägg aldrig tokens i URL-frågeparametrar för autentisering
  • Ställ in Referrer-Policy: strict-origin-when-cross-origin (eller striktare)
  • Om du måste lägga tokens i URL:er (t.ex. e-postverifieringslänkar), gör dem engångs- och kortlivade
typescript
// I din Next.js middleware eller layout
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

JWT-nyckelinjektion#

En mindre välkänd attack: vissa JWT-bibliotek stöder en jwk- eller jku-header som berättar för verifieraren var den ska hitta den publika nyckeln. En angripare kan:

  1. Generera sitt eget nyckelpar
  2. Skapa en JWT med sin payload och signera den med sin privata nyckel
  3. Sätta jwk-headern att peka på sin publika nyckel

Om ditt bibliotek blint hämtar och använder nyckeln från jwk-headern verifieras signaturen. Lösningen: låt aldrig tokenen specificera sin egen verifieringsnyckel. Använd alltid nycklar från din egen konfiguration.

Min auth-stack 2026#

Efter år av att bygga autentiseringssystem, här är vad jag faktiskt använder idag.

För de flesta projekt: Auth.js + PostgreSQL + Passkeys#

Detta är min standardstack för nya projekt:

  • Auth.js (v5) för det tunga arbetet: OAuth-leverantörer, sessionshantering, CSRF, databasadapter
  • PostgreSQL med Prisma-adapter för session- och kontolagring
  • Passkeys via SimpleWebAuthn som primär inloggningsmetod för nya användare
  • E-post/lösenord som reserv för användare som inte kan använda passkeys
  • TOTP MFA som en andra faktor för lösenordsbaserade inloggningar

Sessionsstrategin är databasbackad (inte JWT), vilket ger mig omedelbar återkallelse och enkel sessionshantering.

typescript
// Detta är min typiska auth.ts för ett nytt projekt
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "database" },
  providers: [
    Google,
    GitHub,
    Passkey({
      // Auth.js v5 har inbyggt passkey-stöd
      // Detta använder SimpleWebAuthn under huven
    }),
  ],
  experimental: {
    enableWebAuthn: true,
  },
});

När du ska använda Clerk eller Auth0 istället#

Jag tar till en hanterad auth-leverantör när:

  • Projektet behöver enterprise SSO (SAML, SCIM). Att implementera SAML korrekt är ett flermånadersprojekt. Clerk gör det direkt ur lådan.
  • Teamet saknar säkerhetsexpertis. Om ingen i teamet kan förklara PKCE bör de inte bygga auth från grunden.
  • Time to market är viktigare än kostnad. Auth.js är gratis men tar dagar att konfigurera ordentligt. Clerk tar en eftermiddag.
  • Du behöver efterlevnadsgarantier (SOC 2, HIPAA). Hanterade leverantörer sköter efterlevnadscertifieringen.

Avvägningarna med hanterade leverantörer:

  • Kostnad: Clerk debiterar per månatlig aktiv användare. I stor skala summerar det sig.
  • Leverantörsinlåsning: Att migrera bort från Clerk eller Auth0 är smärtsamt. Din användartabell finns på deras servrar.
  • Anpassningsgränser: Om ditt auth-flöde är ovanligt kommer du att kämpa mot leverantörens åsikter.
  • Latens: Varje auth-kontroll går till ett tredjeparts-API. Med databassessioner är det en lokal fråga.

Vad jag undviker#

  • Att rulla min egen kryptografi. Jag använder jose för JWT:er, @simplewebauthn/server för passkeys, bcrypt eller argon2 för lösenord. Aldrig handrullat.
  • Att lagra lösenord i SHA256. Använd bcrypt (kostnadsfaktor 12+) eller argon2id. SHA256 är för snabbt — en angripare kan testa miljarder hashar per sekund med en GPU.
  • Långlivade åtkomsttokens. Max 15 minuter. Använd refresh token rotation för längre sessioner.
  • Symmetriska hemligheter för verifiering mellan tjänster. Om flera tjänster behöver verifiera tokens, använd RS256 med ett publikt/privat nyckelpar.
  • Anpassade sessions-ID:n med otillräcklig entropi. Använd crypto.randomBytes(32) minimum. UUID v4 är acceptabelt men har mindre entropi än rå slumpmässiga bytes.

Lösenordshashning: Det korrekta sättet#

Eftersom vi nämnde det — så här hashar du lösenord korrekt 2026:

typescript
import { hash, verify } from "@node-rs/argon2";
 
// Argon2id är den rekommenderade algoritmen
// Dessa är rimliga standardvärden för en webbapplikation
async function hashPassword(password: string): Promise<string> {
  return hash(password, {
    memoryCost: 65536,  // 64 MB
    timeCost: 3,        // 3 iterationer
    parallelism: 4,     // 4 trådar
  });
}
 
async function verifyPassword(
  password: string,
  hashedPassword: string
): Promise<boolean> {
  try {
    return await verify(hashedPassword, password);
  } catch {
    return false;
  }
}

Varför argon2id framför bcrypt? Argon2id är minnesintensivt, vilket innebär att attackera det kräver inte bara CPU-kraft utan också stora mängder RAM. Detta gör GPU- och ASIC-attacker avsevärt dyrare. Bcrypt är fortfarande okej — det är inte brutet — men argon2id är det bättre valet för nya projekt.

Säkerhetschecklista#

Innan du levererar något autentiseringssystem, verifiera:

  • Lösenord hashas med argon2id eller bcrypt (kostnad 12+)
  • Sessioner regenereras efter inloggning (förhindrar sessionsfixering)
  • Cookies är HttpOnly, Secure, SameSite=Lax eller Strict
  • JWT:er anger algoritmer uttryckligen (lita aldrig på alg-headern)
  • Åtkomsttokens går ut inom 15 minuter eller mindre
  • Refresh token rotation är implementerad med återanvändningsdetektering
  • OAuth state-parameter verifieras (CSRF-skydd)
  • Omdirigeringsadresser valideras mot en vitlista
  • Rate limiting tillämpas på inloggnings-, registrerings- och lösenordsåterställningsendpoints
  • Misslyckade inloggningsförsök loggas med IP men inte med lösenord
  • Kontolåsning efter N misslyckade försök (med progressiva fördröjningar, inte permanent låsning)
  • Lösenordsåterställningstokens är engångs- och går ut inom 1 timme
  • MFA-säkerhetskoder hashas som lösenord
  • CORS är konfigurerat att bara tillåta kända ursprung
  • Referrer-Policy-header är satt
  • Ingen känslig data i JWT-payloads (de är läsbara av vem som helst)
  • WebAuthn-räknaren verifieras och uppdateras (förhindrar kloning av uppgifter)

Den här listan är inte uttömmande, men den täcker de sårbarheter jag har sett oftast i produktionssystem.

Avslutning#

Autentisering är ett av de domäner där landskapet hela tiden utvecklas, men grunderna förblir desamma: verifiera identitet, utfärda minsta nödvändiga uppgifter, kontrollera behörigheter vid varje gräns och anta intrång.

Den största förändringen 2026 är att passkeys blir mainstream. Webbläsarstödet är universellt, plattformsstöd (iCloud Keychain, Google Lösenordshanterare) gör UX:en sömlös, och säkerhetsegenskaperna är genuint överlägsna allt vi haft tidigare. Om du bygger en ny applikation, gör passkeys till din primära inloggningsmetod och behandla lösenord som reserv.

Den näst största förändringen är att rulla sin egen auth har blivit svårare att motivera. Auth.js v5, Clerk och liknande lösningar hanterar de svåra delarna korrekt. Det enda skälet att gå anpassat är när dina krav genuint inte passar någon befintlig lösning — och det är sällsyntare än de flesta utvecklare tror.

Vad du än väljer, testa din auth som en angripare skulle göra. Försök spela upp tokens, förfalska signaturer, komma åt rutter du inte borde och manipulera omdirigeringsadresser. Buggarna du hittar före lansering är de som inte hamnar i nyheterna.

Relaterade inlägg