Skip to main content

Logout (Back-Channel & RP-Initiated)

A session exists in two places once someone signs in: yours, and ours. Ending one does not end the other by itself. This page is the contract for keeping them in step.

Who tells whom​

EventWhat happensWhat you do
User ends the session from their phone, or an admin revokes itWe POST a signed logout token to your backchannelLogoutURIDestroy the local session for that sid
User signs out of your appNothing reaches us automaticallyPOST to our end-session endpoint, or leave our session running — your choice
Our session expiresNothing is sentNothing. By then every token tied to it has expired anyway

Two consequences worth planning for.

We do not see your logout unless you tell us. Signing a user out of your own app ends your session and nothing else. To end theirs here as well, call the end-session endpoint with the ID token you were issued:

POST https://v1.0account.com/oauth/logout
id_token_hint=<the id_token>

No browser and no redirect — your server can call it directly. OIDC defines RP-initiated logout as a front-channel redirect, which we also accept; the POST form exists because ending a session should not require the user to be present. See session termination below.

sid is the join key, and it is stable. It arrives in the ID token, it is what a logout token names, and it does not change while the session lives. Store it next to your own session record — without it you cannot act on a logout token, because sub alone would log the user out of every device at once.

Expiry is silent on purpose. We do not notify when a session simply ages out. Your session has its own lifetime, which you chose; by the time ours lapses every access and refresh token tied to it has already stopped working, so a notification would tell you nothing you could not already see. If you want to detect revocation between logout tokens, poll /oauth/userinfo: it stops answering for a session that has ended.

How it works​

  1. Your app registers a backchannelLogoutURI (e.g. https://yourapp.com/auth/backchannel-logout).
  2. When a session ends on 0account's side, 0account POSTs a signed Logout Token (JWT) to that URI.
  3. Your endpoint verifies the token and destroys the matching local session.

Logout Token​

The Logout Token is a short-lived JWT signed the same way as ID tokens. Verify it the same way. Required claims:

ClaimValue
isshttps://v1.0account.com
audYour client_id
subThe same sub you were issued in the ID token
sidSession ID
iatIssued-at (Unix timestamp)
expExpiry (Unix timestamp) — five minutes after iat
jtiUnique token ID
events{"http://schemas.openid.net/event/backchannel-logout": {}}

A logout token never carries nonce; reject one that does.

sub is your pairwise subject, not a profile ID

Subjects are pairwise, so the sub in a logout token is the per-client identifier you were issued at sign-in — not an internal 0account profile ID, and not the value another app receives for the same person. Match it against the sub you stored, and prefer sid anyway: sub alone would log the user out of every device at once.

Go — Receiver Endpoint​

package main

import (
"context"
"net/http"

"github.com/coreos/go-oidc/v3/oidc"
)

var logoutVerifier *oidc.IDTokenVerifier

func init() {
provider, err := oidc.NewProvider(context.Background(), "https://v1.0account.com")
handleError(err)
logoutVerifier = provider.Verifier(&oidc.Config{
ClientID: "YOUR_CLIENT_ID",
ClientSecret: "YOUR_CLIENT_SECRET",
// add more config if needed (e.g. RedirectURIs, etc.)
})
}

// POST /auth/backchannel-logout
func handleBackchannelLogout(w http.ResponseWriter, r *http.Request) {
logoutToken := r.FormValue("logout_token")
if logoutToken == "" {
http.Error(w, "missing logout_token", http.StatusBadRequest)
return
}

token, err := logoutVerifier.Verify(r.Context(), logoutToken)
if err != nil {
http.Error(w, "invalid logout token", http.StatusUnauthorized)
return
}

var claims struct {
UserID string `json:"sub"`
SID string `json:"sid"`
Events map[string]any `json:"events"`
}
token.Claims(&claims)

if _, ok := claims.Events["http://schemas.openid.net/event/backchannel-logout"]; !ok {
http.Error(w, "not a logout token", http.StatusBadRequest)
return
}

// Destroy session by UserID (sub) or Session ID (sid)
destroySessionByUserID(claims.UserID)
w.WriteHeader(http.StatusOK)
}

Node.js — Receiver Endpoint (openid-client)​

const { Issuer } = require("openid-client");
const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: true }));

let client;
async function init() {
const issuer = await Issuer.discover("https://v1.0account.com");
client = new issuer.Client({
client_id: process.env.ZERO_CLIENT_ID,
client_secret: process.env.ZERO_CLIENT_SECRET,
});
}

// POST /auth/backchannel-logout
app.post("/auth/backchannel-logout", async (req, res) => {
try {
const token = await client.validateBackchannelLogoutToken(
req.body.logout_token,
);
// token.sid — session ID; token.sub — user ID
await destroySession(token.sid);
res.sendStatus(200);
} catch (err) {
res.sendStatus(400);
}
});

init().then(() => app.listen(3000));

Session Termination (Server-to-Server)​

Rather than redirecting the user's browser to 0account, your backend can call the logout endpoint directly. POST with only id_token_hint and 0account returns 200 OK — no redirect involved.

Go:

http.PostForm("https://v1.0account.com/oauth/logout", url.Values{
"id_token_hint": {storedIDToken},
})

Node.js:

await fetch("https://v1.0account.com/oauth/logout", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ id_token_hint: storedIdToken }),
});

This is the recommended approach. The flow becomes:

Frontend  →  DELETE /auth/logout  (your backend)
Backend → POST https://v1.0account.com/oauth/logout (server-to-server)
← 200 OK
Backend → destroy local session, respond to frontend
Frontend ← redirect wherever you want
tip

Register backchannelLogoutURI to handle logout events triggered from outside your app (mobile app, another device, 0account dashboard), and call the server-to-server logout endpoint when the user explicitly signs out in your UI.

RP-Initiated Logout (browser redirect)

Most integrations do not need this — the POST above ends the session from your server and your frontend redirects wherever it likes. Use this form if a library drives logout for you (Spring Security, openid-client's endSessionUrl()), or if you want 0account to send the browser back.

https://v1.0account.com/oauth/logout
?id_token_hint=<id-token>
&post_logout_redirect_uri=https://yourapp.com/logged-out
&state=<optional-random>

Register the URL under Post-logout redirect URIs in your app settings, directly below Backchannel logout URI. Matching is exact — scheme, port and query all count — and an exact redirect-URI match also works. We then redirect to post_logout_redirect_uri?state=<state>.

Omit it or get it wrong and the session still ends; we answer 200 OK and the browser stays put. Where the browser lands cannot decide whether the session ends.