Skip to content
academia.sh

Lesson 14 / 19

Token-Based Authentication

In a stateless session, everything about identity stops at the claims a token carries; there is no session record on the server side at all. Whether an authorization question is answered by an annotation or by a comparison in the source is separated by running it; the limiting measurement shows, by count, how many steps a revoked token keeps being treated as valid until it expires, because the validator never looks at any revocation list at all.

Published

In this lesson

  • Measures how many claims a token carries and shows that no session record is kept on the server side at all
  • Compares, by running it, whether the same authorization question is answered by an annotation or by a comparison in the source
  • Measures how many steps a revoked token keeps being counted as valid until it expires, because the validator never looks at the revocation list at all
Contents

The previous lesson took the caller’s role as a ready-made string and never asked where that string came from. This lesson asks exactly that question. A server can know who a caller is in one of two ways: either it keeps a record for that caller on its own side for every request, or it stores everything about identity in a token the caller carries, and keeps nothing on its own side at all. The second is a stateless session: the server’s memory holds zero records about the caller, and the identity of every request arrives inside that request itself. This lesson models the token — without writing a real key, a real signature algorithm, or a real cryptography routine — and measures two things: the number of claims the token carries, and whether the answer to an authorization question comes from an annotation or from a call in the source. The limiting measurement is saved for last, because statelessness has a cost, and this cost is visible only by running the token through its lifetime.

In a session-based approach the server keeps a record for every caller; deleting the record invalidates the caller instantly, because every request already looks at that record. The stateless approach never performs that lookup at all — proof of identity arrives with every request, and there is no record to query on the server side. This difference is not just an implementation detail; it is exactly what this lesson measures.

The Claims a Token Carries

The modeled token carries two fields: a subject (the caller’s identity) and a map of claims — every piece of information carried about the subject is a key-value pair in this map. There is a third field, an expiry, but it is not a real timestamp: it is an integer that counts steps, used later in the lesson. There is no real clock, no real duration, and no real signature; the token itself is modeled as an ordinary data carrier.

// Token.java — stateless data carrier holding everything about identity; expiry is not real time, it is an integer that counts steps (helper source, no main)
import java.util.*;

record Token(String subject, Map<String, String> claims, int expiry) { }

class Validator {
    // stateless validation: looks only at the token's own fields and the given moment,
    // never accessing any shared state (a revocation list, a session store).
    static boolean valid(Token t, int now) {
        return now <= t.expiry();
    }

    static void show(String label, Object value) {
        System.out.printf("%-42s: %s%n", label, value);
    }
}
// ClaimCount.java — the number of claims carried by two tokens is counted; the number of session records kept server-side is zero
import java.util.*;

public class ClaimCount {
    public static void main(String[] args) {
        Token guest = new Token("user-7", Map.of("role", "guest"), 10);
        Token manager = new Token("user-2",
                Map.of("role", "manager", "department", "IT", "level", "3"), 10);

        Validator.show("guest token, number of claims carried", guest.claims().size());
        Validator.show("manager token, number of claims carried", manager.claims().size());
        Validator.show("number of session records kept server-side", 0);
    }
}
guest token, number of claims carried     : 1
manager token, number of claims carried   : 3
number of session records kept server-side: 0

The guest token carries one claim, manager carries three. This count is not fixed — every token is as heavy as the information it carries, and there is no structure on the server side that holds this information a second time. ST6. The token carries all information about identity within its own claims, with no session record kept on the server side at all; this is the definition of a stateless session. ST7. The number of claims carried can vary from token to token, but the count of records kept on the server side is always zero — there is no structure here like the endpoints map of the Gatekeeper measured in the previous lesson, because there is no caller state left to measure in the first place.

A real token system protects its claims with a signature — a validation step that checks whether the token’s content has been tampered with. This lesson does not write that step; signatures, keys, and cryptography routines were built in the Identity, Access and Cryptography course and are not repeated here. Here Token is an ordinary record, unsigned and produced directly; what is measured is not the signature itself but the two questions that remain even when the signature is correct — how much information a claim carries, and how that information is read.

The main gain of statelessness in this topic is that the party validating identity needs no shared record at all. Which component handles a given call becomes irrelevant, because every component can validate the token on its own, using only the information it already holds — it never needs to ask a previous component for a record it kept. This property will reappear in the next two lessons: the distributed configuration lesson will ask whether multiple components see the same value, and the service discovery lesson will find that which component a call reaches is never fixed at all. Stateless identity is one reason that variability never becomes a problem.

The Source of Validation: Annotation or Call

Whether the claims a token carries are true is a separate question; this lesson measures only how claims are read — where does the answer to “is the role claim manager” come from? Two paths are modeled. The first is an annotation: a @RequiredClaim mark placed on a method carries the required key and the expected value, and the party that reads and compares it is not the caller but the check class itself.

// RequiredClaim.java — annotation type stating which claim, with which value, a token must carry for a method to pass (helper source, no main)
import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
@interface RequiredClaim { String name(); String value(); }
// ClaimCheck.java — check that reads the annotation and compares it against the token's matching claim (helper source, no main)
import java.lang.reflect.*;

class ClaimCheck {
    static boolean matches(Method m, Token t) {
        RequiredClaim r = m.getAnnotation(RequiredClaim.class);
        if (r == null) return true;
        return r.value().equals(t.claims().get(r.name()));
    }
}
// HiddenEndpoints.java — the mark above the panel method states which claim is required (helper source, no main)
class HiddenEndpoints {
    @RequiredClaim(name = "role", value = "manager")
    String panel() { return "panel-data"; }
}

The second path is a comparison written directly in the source; no mark is read, no reflection runs.

// ManualValidation.java — the same question is answered from a comparison written directly in the source (helper source, no main)
class ManualValidation {
    static boolean isManager(Token t) {
        return "manager".equals(t.claims().get("role"));
    }
}
// ValidationSource.java — with the same two tokens, the annotation-based and the call-based check are compared, producing the same result
import java.lang.reflect.*;
import java.util.*;

public class ValidationSource {
    public static void main(String[] args) throws Exception {
        Token guest = new Token("user-7", Map.of("role", "guest"), 10);
        Token manager = new Token("user-2", Map.of("role", "manager"), 10);

        Method panel = HiddenEndpoints.class.getDeclaredMethod("panel");

        Validator.show("annotation: panel, guest token", ClaimCheck.matches(panel, guest));
        Validator.show("annotation: panel, manager token", ClaimCheck.matches(panel, manager));
        Validator.show("call: manualValidation, guest token", ManualValidation.isManager(guest));
        Validator.show("call: manualValidation, manager token", ManualValidation.isManager(manager));
    }
}
annotation: panel, guest token            : false
annotation: panel, manager token          : true
call: manualValidation, guest token       : false
call: manualValidation, manager token     : true

The results of the four lines are pairwise identical: ClaimCheck.matches and ManualValidation.isManager give the same two answers for the same two tokens. ST8. The same “is the role manager” question can be answered by reading an annotation: the source of the panel method contains neither the word “role” nor a comparison, the decision sits in the @RequiredClaim mark. ST9. The same question can also be answered from a comparison written in the source: the first line of the isManager method carries the claims().get("role") call and the comparison directly. Two sources produce the same result, but someone reading the source of HiddenEndpoints never sees the word “manager” at all, while someone reading the source of ManualValidation finds it in the first line.

ClaimCheck.matches also has a third case: when there is no @RequiredClaim mark at all, the method returns true directly — the same as the default-open setup measured in the previous lesson, here in a one-line shortcut. This shows the real difference between annotation and call: annotation-based checking is defined in one place for the whole HiddenEndpoints class, granting access by default when a new method is added and requiring a mark only where needed. In call-based checking, every new method must write its own comparison; if a method forgets this line, the omission appears nowhere at all — the source compiles cleanly, runs, and stays unchecked.

The Cost of Statelessness: A Revoked Token’s Lifetime

Validator.valid looks at only two things: the token’s own expiry field and the now value given to it. This gives rise to the lesson’s most limiting measurement: even if a token is “revoked,” if the validator has no revocation list to look at, the revocation record never affects the token’s validity at all. An application can keep its own revocation list on its own side, but valid never accesses that list at all — if it did, it would stop being stateless and become a validator that looks at a shared structure on every call.

// RevocationAndValidity.java — a revoked token keeps being counted as valid until it expires, because the validator never looks at the revocation list at all
import java.util.*;

public class RevocationAndValidity {
    public static void main(String[] args) {
        Token token = new Token("user-9", Map.of("role", "employee"), 10);
        Set<String> revoked = new HashSet<>();

        Validator.show("before revocation, moment=0, valid", Validator.valid(token, 0));

        revoked.add(token.subject());
        Validator.show("subject in revocation list", revoked.contains(token.subject()));

        int validRemainingSteps = 0;
        for (int moment = 1; moment <= 15; moment++) {
            if (Validator.valid(token, moment)) validRemainingSteps++;
        }
        Validator.show("steps validator counts as valid after revocation", validRemainingSteps);
        Validator.show("moment=11, valid (expired)", Validator.valid(token, 11));
    }
}
before revocation, moment=0, valid        : true
subject in revocation list                : true
steps validator counts as valid after revocation: 10
moment=11, valid (expired)                : false

The revoked set holds token.subject() — the application made a decision about this subject. But Validator.valid knows nothing about this set’s existence; it is never given as a parameter, never queried at all. ST10. Stateless validation looks only at the token’s own fields and the given moment; it has no access to shared state such as a revocation list. ST11. A revoked token keeps being counted as valid until it expires, because the validator never looks at the revocation list — in this lesson that duration is ten steps, and at the eleventh step valid returns false on its own because expiry is exceeded. This ten-step window comes from the token’s own expiry value; a shorter expiry narrows this window, but never to zero. To verify this without leaving it as a bare claim, the same measurement is repeated with a shorter expiry.

// ShortWindow.java — when the expiry is shortened, the number of steps counted as valid after revocation shrinks by the same ratio
import java.util.*;

public class ShortWindow {
    public static void main(String[] args) {
        Token shortLived = new Token("user-11", Map.of("role", "employee"), 3);

        int validRemainingSteps = 0;
        for (int moment = 1; moment <= 15; moment++) {
            if (Validator.valid(shortLived, moment)) validRemainingSteps++;
        }
        Validator.show("expiry=3 token, valid steps counted after revocation", validRemainingSteps);
    }
}
expiry=3 token, valid steps counted after revocation: 3

When expiry is given as three instead of ten, the number of steps counted as valid after revocation also becomes three instead of ten — the window narrows, proportionally. But it never reaches zero, because the valid call itself is still only a number comparison; for the validator to see revocation instantly, it would need to look at shared state, and adding that look would take it out of being stateless. This is the lesson’s limiting measurement: statelessness is not free, its cost is measured by the delay of revocation, and this delay — even as it shrinks with a shorter expiry — never reaches zero.

Placed side by side, the lesson’s three measurements repeat the same pattern: information that sits, or never sits, in the source only turns into a number by being run.

Measurement Observed in this lesson
number of claims (guest / manager) 1 / 3
validation source (annotation / call) both produce the same result
valid steps remaining after revocation (expiry 10) 10
valid steps remaining after revocation (expiry 3) 3

The last two rows run the same mechanism with two different parameters and show that the cost is not fixed but proportional to the expiry — a relationship that emerges not from a number written in the source but from comparing two runs.

Summary

  • The token carries information about identity within its own claims with no session record kept on the server side; the number of claims carried varies from token to token.
  • An authorization question can be answered by reading an annotation (@RequiredClaim); the decision gives rise to no call expression in the source.
  • The same question can also be answered by a comparison written in the source (a call); the two sources produce the same result, from different places.
  • Stateless validation looks only at the token’s own fields and the given moment; it has no access to any shared state, such as a revocation list.
  • A revoked token keeps being counted as valid until it expires, because the validator never looks at the revocation list; this is a cost whose duration in steps can be counted.

Next Step

These two lessons measured the source of the authorization decision through a single call or a single token. How many components a container actually brings up, and which parts a given test sets up, has not yet been asked at all. The next lesson puts the same container into a test setting and counts two setups side by side: how many components a slice test brings up, how many a full-context test brings up — and what happens to an annotation left outside the slice.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close