Authelia authentication

A gate of silence waits ahead,

no key, no plea, no hurried tread— but speak the truth, the name you own, and paths once barred become your own.

OpenID Connect becomes confusing when it is described as a list of endpoints and tokens. The easier way to understand it is as a conversation between three parties:

  • the browser, which carries the user between services;
  • the application backend, which wants to know who the user is;
  • Authelia, which authenticates the user and can prove the result.

The application never receives the user’s password. It sends the browser to Authelia, Authelia performs the login, and the application receives a signed statement about the resulting identity. The application verifies that statement and then creates its own session.

This distinction is the heart of the entire flow:

Authelia authenticates the user. The application decides what that user may do.

OpenID Connect (OIDC) is an identity layer built on OAuth 2.0. OAuth is mainly concerned with delegated access to resources. OIDC adds the openid scope and an ID token, giving the client a standard way to verify an authentication event and identify the user.

In this guide, Authelia is the OpenID Provider (OP), and a Node/Express backend is the Relying Party (RP), also called the client. Google, Microsoft Entra, and other providers follow the same protocol shape, but their registration settings and available claims differ.

The Mental Model

Before looking at code, it helps to give each participant one job.

ParticipantOIDC nameResponsibility
BrowserUser agentCarries redirects, the authorization code, and the application session cookie.
Application backendRelying Party / clientStarts login, keeps one-time values and the client secret, validates the result, then creates a local session.
AutheliaOpenID ProviderAuthenticates the user, asks for consent when configured, and issues tokens.
Session storeApplication componentStores the pending login transaction and, after login, the application’s user session.

The frontend is not handling tokens in this example. The browser follows redirects, but the backend is the OIDC client. That is why the backend can safely hold a client secret and why the token exchange happens server-to-server.

Trust Is Configured Before Login

OIDC does not start with a random application asking Authelia to identify someone. A trust relationship is registered first:

  • Authelia knows the application’s client ID.
  • Authelia stores a hash of the client secret.
  • The application stores the original client secret.
  • Authelia allowlists the exact callback URI.
  • Both sides agree on the issuer, scopes, response type, PKCE, and token-endpoint authentication method.

This registration is what lets Authelia reject an unknown client or an unexpected callback destination before a user is sent anywhere unsafe.

The Authorization Code Flow

This guide uses the Authorization Code flow with PKCE. The browser receives only a short-lived, one-time authorization code. The tokens are returned later over a direct HTTPS connection between the application and Authelia.

OpenID Connect authorization code flow between the browser, application backend, and Authelia

The browser carries redirects on the front channel. The application exchanges the code for tokens on the back channel.

1. The User Requests a Protected Page

The browser asks the application for something such as /account. The backend checks its own session store and finds no logged-in user.

At this point, the application does not ask Authelia, “Is this request allowed?” Instead, it starts a new authentication transaction. In the club analogy, the bouncer sees no entry stamp and directs the visitor to the registration desk.

2. The Application Creates One-Time Values

Before redirecting the browser, the backend generates three values:

  • state binds the callback to the login attempt that started it and protects the authorization response from cross-site request forgery.
  • nonce binds the ID token to this authentication request and helps prevent token replay.
  • PKCE code_verifier is a secret created for this one attempt. Its derived code_challenge is sent to Authelia, while the verifier remains in the application’s session.

The backend stores state, nonce, and code_verifier in the pending server-side session. It sends the browser an authorization URL containing the client ID, callback URI, requested scopes, state, nonce, and the PKCE challenge.

These are transaction values, not long-lived configuration. Generate new ones for every login and consume them only once.

3. The Browser Visits Authelia

The application replies with an HTTP redirect, and the browser follows it to Authelia. The user’s credentials and second factor are entered there. They never pass through the application.

Authelia now knows two separate things:

  1. which user authenticated;
  2. which registered client asked for that authentication.

If the request is valid and the configured policy is satisfied, Authelia creates a short-lived authorization code.

4. Authelia Returns an Authorization Code

Authelia redirects the browser to the exact registered callback URI:

https://app.example.com/oidc/callback?code=abc123&state=...

The code is not the user’s identity, and simply decoding or storing it would achieve nothing. It is a one-time credential that the application must redeem at Authelia’s token endpoint.

The browser can see this code because it carries the redirect. That is safe only because the code is short-lived, single-use, tied to the callback URI, and—in this flow—bound to the application’s PKCE verifier.

5. The Backend Exchanges the Code

The backend first compares the returned state with the value saved in the session. It then makes a direct HTTPS request to Authelia’s token endpoint containing:

  • the authorization code;
  • the exact same callback URI;
  • the private PKCE code_verifier;
  • the client ID and client authentication.

Authelia checks that the code is valid, unused, issued to this client, and paired with the correct verifier. Only then does it return an ID token and an access token to the backend.

The browser is not involved in this exchange. The client secret, PKCE verifier, ID token, and access token therefore do not need to enter browser storage.

6. The Application Validates the Result

Receiving a JWT is not enough. The client must validate it. A maintained OIDC library performs the protocol checks using Authelia’s discovery metadata and published JSON Web Key Set (JWKS), including:

  • the signature and signing key;
  • the exact issuer (iss);
  • the intended audience (aud);
  • expiry (exp);
  • the expected nonce;
  • the authorization response state and PKCE binding.

The important identity claim is sub, the subject identifier. Within an issuer, it is the stable identifier intended for linking an OIDC identity to a local account. Email addresses and display names can change.

7. The Application Creates Its Own Session

Once validation succeeds, OIDC has done its job. The application takes the verified issuer, subject, and any claims it deliberately uses, then stores them in its own server-side session.

The browser receives an opaque application session cookie. On later requests, the backend checks that local session instead of repeating the full OIDC flow.

There are now two sessions:

  • an Authelia session, which enables single sign-on across clients;
  • an application session, which represents login to this one application.

That separation explains why logging out of the application does not necessarily log the user out of Authelia.

What Each Value Is For

ValuePurposeWhere it should live
Authorization codeOne-time input to the token exchange; not proof of identity by itself.Briefly visible in the browser callback, then consumed by the backend.
ID tokenSigned claims about the authentication event and user, intended for this client.Validated and retained only on the backend when needed.
Access tokenAuthorizes calls to a protected resource or UserInfo endpoint. It is not the application’s session cookie.Backend only in this example.
stateBinds the authorization response to the login attempt.Server-side pending session; echoed through the browser.
nonceBinds the ID token to the authentication request.Server-side pending session; included in the authorization request and ID token.
PKCE verifierProves that the client redeeming the code started the flow.Server-side pending session only.
Application session cookieIdentifies the application’s local session after OIDC completes.Browser cookie, with the session data stored server-side.

Register the Client in Authelia

The example assumes:

SettingValue
Authelia issuerhttps://auth.example.com
Applicationhttps://app.example.com
Callback URIhttps://app.example.com/oidc/callback
Client typeConfidential
FlowAuthorization Code with PKCE S256
Token authenticationclient_secret_basic

Authelia’s provider-level OIDC configuration and signing key must already exist. This section adds the registered client.

Generate the Client ID and Secret

Authelia can generate a random client ID:

authelia crypto rand --length 72 --charset rfc3986

It can also generate a random raw secret and its PBKDF2 hash:

authelia crypto hash generate pbkdf2 \
  --variant sha512 \
  --random \
  --random.length 72 \
  --random.charset rfc3986

The two secret forms have different destinations:

  • put the hash in Authelia’s client_secret setting;
  • put the raw secret in the application’s OIDC_CLIENT_SECRET environment variable.

Do not put the hash in both places. Authelia compares the raw secret presented by the application with the stored hash.

Add the Client

identity_providers:
  oidc:
    # Provider signing keys and other mandatory provider settings go here.
    clients:
      - client_id: 'replace-with-the-generated-client-id'
        client_name: 'My Application'
        client_secret: '$pbkdf2-sha512$...'
        public: false
        authorization_policy: 'two_factor'
        require_pkce: true
        pkce_challenge_method: 'S256'
        redirect_uris:
          - 'https://app.example.com/oidc/callback'
        scopes:
          - 'openid'
          - 'profile'
          - 'email'
        response_types:
          - 'code'
        grant_types:
          - 'authorization_code'
        token_endpoint_auth_method: 'client_secret_basic'

The callback URI is an exact, case-sensitive allowlist entry. Scheme, hostname, port, path, and trailing slash must match what the application sends.

The token_endpoint_auth_method is also important. Authelia defaults confidential clients to client_secret_basic. The JavaScript client will be configured explicitly to use the same method.

The current options and defaults are documented in Authelia’s client configuration reference. Its OIDC FAQ explains client-secret generation and the distinction between the raw secret and stored hash.

Build the Node/Express Client

This example targets Node.js 20 or newer and openid-client 6.8.7 or newer within the 6.x release line. It uses a confidential backend client, a persistent SQLite session store, and ESM imports.

Install the Dependencies

npm install openid-client@^6.8.7 express express-session connect-sqlite3

Express’s default MemoryStore is for development only. Use a persistent store appropriate to the deployment, such as Redis, a database-backed store, or the SQLite store shown here.

Configure the Environment

OIDC_ISSUER=https://auth.example.com
OIDC_CLIENT_ID=replace-with-the-generated-client-id
OIDC_CLIENT_SECRET=replace-with-the-raw-client-secret
OIDC_REDIRECT_URI=https://app.example.com/oidc/callback
SESSION_SECRET=replace-with-a-separate-long-random-secret

Use the issuer root URL, not a manually copied authorization or token endpoint. Discovery obtains the correct endpoints and key metadata from:

https://auth.example.com/.well-known/openid-configuration

Discover Authelia and Configure Sessions

import express from "express";
import session from "express-session";
import connectSqlite3 from "connect-sqlite3";
import * as oidc from "openid-client";

const {
  OIDC_ISSUER,
  OIDC_CLIENT_ID,
  OIDC_CLIENT_SECRET,
  OIDC_REDIRECT_URI,
  SESSION_SECRET,
} = process.env;

if (
  !OIDC_ISSUER ||
  !OIDC_CLIENT_ID ||
  !OIDC_CLIENT_SECRET ||
  !OIDC_REDIRECT_URI ||
  !SESSION_SECRET
) {
  throw new Error("Missing required OIDC or session environment variable");
}

const app = express();

// Use the correct value for your proxy topology. Do not blindly trust all proxies.
app.set("trust proxy", 1);

const issuer = new URL(OIDC_ISSUER);
const redirectUri = new URL(OIDC_REDIRECT_URI).href;

const config = await oidc.discovery(
  issuer,
  OIDC_CLIENT_ID,
  { client_secret: OIDC_CLIENT_SECRET },
  oidc.ClientSecretBasic(OIDC_CLIENT_SECRET),
);

const SQLiteStore = connectSqlite3(session);
const sessionStore = new SQLiteStore({
  db: "sessions.sqlite",
  dir: "/var/lib/my-app",
});

app.use(session({
  name: "app_session",
  secret: SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  store: sessionStore,
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 8 * 60 * 60 * 1000,
  },
}));

Passing Authelia’s issuer to discovery() avoids hard-coding its authorization, token, and JWKS endpoints. The explicit ClientSecretBasic call matches the Authelia client registration; current openid-client otherwise defaults discovery-created clients to client_secret_post.

Start Login

app.get("/login", async (req, res, next) => {
  try {
    const codeVerifier = oidc.randomPKCECodeVerifier();
    const codeChallenge =
      await oidc.calculatePKCECodeChallenge(codeVerifier);
    const state = oidc.randomState();
    const nonce = oidc.randomNonce();

    req.session.oidc = { codeVerifier, state, nonce };

    const authorizationUrl = oidc.buildAuthorizationUrl(config, {
      redirect_uri: redirectUri,
      scope: "openid profile email",
      code_challenge: codeChallenge,
      code_challenge_method: "S256",
      state,
      nonce,
    });

    // Persist the verifier and checks before the browser leaves the application.
    req.session.save((error) => {
      if (error) return next(error);
      res.redirect(authorizationUrl.href);
    });
  } catch (error) {
    next(error);
  }
});

This example stores one pending login per browser session. If the application must support several concurrent login attempts in multiple tabs, store pending transactions by state rather than overwriting a single req.session.oidc object.

Handle the Callback

app.get("/oidc/callback", async (req, res, next) => {
  const pending = req.session.oidc;
  delete req.session.oidc;

  if (!pending) {
    return res.status(400).send("No pending login");
  }

  try {
    // Use the known external callback origin instead of trusting a Host header.
    const currentUrl = new URL(req.originalUrl, redirectUri);

    const tokens = await oidc.authorizationCodeGrant(
      config,
      currentUrl,
      {
        pkceCodeVerifier: pending.codeVerifier,
        expectedState: pending.state,
        expectedNonce: pending.nonce,
        idTokenExpected: true,
      },
    );

    const claims = tokens.claims();
    if (!claims?.sub || !claims.iss) {
      throw new Error("Validated ID token has no issuer or subject");
    }

    // Replace the pre-login session ID to prevent session fixation.
    req.session.regenerate((error) => {
      if (error) return next(error);

      req.session.user = {
        issuer: claims.iss,
        id: claims.sub,
        name: claims.name,
        email: claims.email,
      };

      req.session.save((saveError) => {
        if (saveError) return next(saveError);
        res.redirect("/account");
      });
    });
  } catch (error) {
    next(error);
  }
});

authorizationCodeGrant() processes the callback, sends the back-channel token request, selects the correct signing key by kid, and performs the OIDC checks. The application consumes only the validated claims.

The openid-client API reference documents the discovery, authorization-code, PKCE, nonce, state, and client-authentication helpers used here.

Protect a Route

function requireLogin(req, res, next) {
  if (!req.session.user) {
    return res.redirect("/login");
  }
  next();
}

app.get("/account", requireLogin, (req, res) => {
  res.json({ user: req.session.user });
});

app.listen(3000, () => {
  console.log("Application listening on port 3000");
});

The local account key should be the pair iss + sub. Do not use email as the primary identifier, and do not treat authentication as authorization. Application roles should come from deliberate, validated claims or the application’s own database.

Understanding the ID Token

An ID token is usually a signed JWT with three dot-separated parts:

base64url(header).base64url(payload).base64url(signature)

The payload may resemble:

{
  "iss": "https://auth.example.com",
  "sub": "4f829a18-...",
  "aud": "my-client-id",
  "exp": 1788093600,
  "iat": 1788090000,
  "nonce": "...",
  "name": "Example User",
  "email": "user@example.com"
}

The most important claims are:

ClaimMeaning
issWho issued the token. It must exactly match the configured issuer.
subThe user’s stable subject identifier at that issuer.
audThe client or clients for which the token was created.
expThe time after which the token is no longer valid.
iatThe time at which the token was issued.
nonceThe value that binds the token to this login attempt.

A JWT is encoded, not encrypted. Anyone who obtains it can usually read its header and payload. Trust comes from validation of its signature and claims, not from the data being hidden.

The ID token is also not a general API credential. It tells this relying party about an authentication event. An access token has a different audience and purpose: authorizing requests to a protected resource. This example needs no external API, so it validates the ID token and then relies on its own application session.

The validation requirements come from the OpenID Connect Core Authorization Code flow, including exact issuer and audience checks.

Logout: Two Sessions, Two Meanings

The application can always destroy its own session:

app.post("/logout", requireLogin, (req, res, next) => {
  req.session.destroy((error) => {
    if (error) return next(error);

    res.clearCookie("app_session", {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      path: "/",
    });
    res.redirect("/");
  });
});

This logs the user out of the application, but it does not end the Authelia session. If the user starts login again, Authelia may recognize the existing SSO session and return them without asking for credentials.

At the time of this update, Authelia’s support chart lists RP-Initiated Logout as unsupported. Do not invent an end-session URL or call buildEndSessionUrl() unless the discovery document for the deployed provider actually contains end_session_endpoint. Other OIDC providers may support it.

Security Checklist

  • Use HTTPS for Authelia, the application, and the callback.
  • Register exact redirect URIs; do not use broad wildcard callbacks.
  • Generate fresh state, nonce, and PKCE values for every login.
  • Keep the raw client secret, PKCE verifier, tokens, and session data on the backend.
  • Use S256 PKCE even for a confidential client.
  • Let an OIDC library perform discovery, JWKS selection, signature verification, and claim validation.
  • Regenerate the application session after login and use HttpOnly, Secure, and an appropriate SameSite cookie policy.
  • Use a persistent session store in production and define expiry and cleanup.
  • Request only the scopes the application needs.
  • Never log authorization codes, tokens, secrets, or complete session cookies.
  • Treat iss + sub as identity; treat roles and access rules as a separate authorization decision.

The old version of this guide manually constructed endpoint URLs, decoded tokens without always verifying them, and selected a JWKS key by hand. Those mechanics are useful to study, but they are unsafe foundations for copyable application code. Discovery and a maintained OIDC client remove several subtle failure modes while keeping the protocol visible in the surrounding explanation.

Troubleshooting the Flow

SymptomLikely cause
redirect_uri error at AutheliaThe URI differs by scheme, hostname, port, path, case, or trailing slash. Compare the configured and requested values exactly.
invalid_client at the token endpointThe application has the hash instead of the raw secret, the raw secret is wrong, or client_secret_basic and client_secret_post do not match.
“No pending login” on callbackThe pre-login session was not saved, its cookie was blocked, or requests are reaching different instances without a shared session store.
State, nonce, or PKCE validation failsThe callback belongs to another or expired login attempt, or concurrent attempts overwrote the pending transaction. Start a fresh login.
Login loops behind a reverse proxyThe application is not seeing the external HTTPS scheme or the session cookie is not being returned. Check proxy headers and trust proxy scope.
Email or name is missingThe scope was not requested, not allowed for the client, or the Authelia user record does not provide the claim.
Logout appears to sign in again immediatelyThe local application session ended, but the Authelia SSO session remains active.

When debugging, log milestones and error categories, not credentials:

  1. login route created and saved a transaction;
  2. browser returned to the exact callback;
  3. token exchange succeeded or returned a named protocol error;
  4. ID token validation succeeded;
  5. local session was regenerated and saved.

That sequence tells you which boundary failed without leaking the values used to cross it.

Further Reading



Buy Me a Coffee