State that survives a restart

Move registered clients, authorizations and consent into PostgreSQL, with Flyway owning the schema.

  • Part 4
  • intermediate
  • about 60 minutes

You will build

PostgreSQL through Docker Compose, three Flyway migrations, and JDBC-backed client, authorization and consent services

You will understand

why Flyway owns the schema while Hibernate only validates it, and why the protocol tables use JDBC while users will use JPA

  • three migrations, three beans

A restart should not erase an authorization server’s world.

We now move the OAuth domain model into PostgreSQL:

Registered clients
Authorizations
Authorization codes
Refresh token state
Consent

The important architectural decision is:

Flyway owns the schema. Hibernate validates schemas it owns, but does not mutate production tables.

Add database dependencies

Add Boot-managed dependencies for:

  • JDBC;
  • PostgreSQL runtime driver;
  • Flyway core;
  • Flyway PostgreSQL support.

Later, JPA will manage our application user/domain entities, but the Spring Authorization Server repositories themselves will remain JDBC-backed.

Local PostgreSQL with Docker Compose

Create compose.yaml:

services:
  postgres:
    image: postgres:17
    container_name: oauth2-postgres
    environment:
      POSTGRES_DB: oauth2_authorization_server
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d oauth2_authorization_server"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

Start:

docker compose up -d

Inspect:

docker compose ps

Application database configuration

spring:
  datasource:
    url: ${DB_URL:jdbc:postgresql://localhost:5432/oauth2_authorization_server}
    username: ${DB_USERNAME:postgres}
    password: ${DB_PASSWORD:postgres}
  flyway:
    enabled: true
    locations: classpath:db/migration
  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false

Even before we add our own JPA entities, establishing ddl-auto=validate gives the project the right schema-management posture.

Avoid:

spring:
  jpa:
    hibernate:
      ddl-auto: update

Automatic production schema mutation makes database changes harder to review, reproduce, roll forward, and coordinate across environments.

Migration 1 — registered clients

Create:

src/main/resources/db/migration/V1__create_oauth2_registered_client.sql

The table needs to represent the full RegisteredClient model, including client settings and token settings serialized by Spring’s JDBC repository.

Core columns include:

CREATE TABLE oauth2_registered_client (
    id VARCHAR(100) PRIMARY KEY,
    client_id VARCHAR(100) NOT NULL UNIQUE,
    client_id_issued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    client_secret VARCHAR(200),
    client_secret_expires_at TIMESTAMP,
    client_name VARCHAR(200) NOT NULL,
    client_authentication_methods VARCHAR(1000) NOT NULL,
    authorization_grant_types VARCHAR(1000) NOT NULL,
    redirect_uris VARCHAR(1000),
    post_logout_redirect_uris VARCHAR(1000),
    scopes VARCHAR(1000) NOT NULL,
    client_settings VARCHAR(2000) NOT NULL,
    token_settings VARCHAR(2000) NOT NULL
);

Then replace the in-memory repository:

@Bean
RegisteredClientRepository registeredClientRepository(
        JdbcTemplate jdbcTemplate
) {
    return new JdbcRegisteredClientRepository(jdbcTemplate);
}

Migration 2 — authorization state

Create V2__create_oauth2_authorization.sql.

This is the large protocol-state table. It stores metadata for:

authorization code
access token
refresh token
ID token
user/device code where applicable
authorized scopes
attributes/state

Use PostgreSQL TEXT columns for serialized token/metadata values rather than database-specific binary assumptions.

Wire the service:

@Bean
OAuth2AuthorizationService authorizationService(
        JdbcTemplate jdbcTemplate,
        RegisteredClientRepository registeredClientRepository
) {
    return new JdbcOAuth2AuthorizationService(
            jdbcTemplate,
            registeredClientRepository
    );
}

Create V3__create_oauth2_authorization_consent.sql:

CREATE TABLE oauth2_authorization_consent (
    registered_client_id VARCHAR(100) NOT NULL,
    principal_name VARCHAR(200) NOT NULL,
    authorities VARCHAR(1000) NOT NULL,
    PRIMARY KEY (registered_client_id, principal_name),
    CONSTRAINT fk_authorization_consent_client
      FOREIGN KEY (registered_client_id)
      REFERENCES oauth2_registered_client(id)
      ON DELETE CASCADE
);

Wire:

@Bean
OAuth2AuthorizationConsentService authorizationConsentService(
        JdbcTemplate jdbcTemplate,
        RegisteredClientRepository registeredClientRepository
) {
    return new JdbcOAuth2AuthorizationConsentService(
            jdbcTemplate,
            registeredClientRepository
    );
}

Architecture after persistence

flowchart TD
    AS[Authorization Server]
    RC[JdbcRegisteredClientRepository]
    AU[JdbcOAuth2AuthorizationService]
    CO[JdbcOAuth2AuthorizationConsentService]
    DB[(PostgreSQL)]
    F[Flyway]

    AS --> RC --> DB
    AS --> AU --> DB
    AS --> CO --> DB
    F -->|versioned migrations| DB

Where should the demo client go now?

Do not keep production application configuration responsible for creating development credentials.

For the moment, you can insert the demo client in development initialization code; in Part 6 we will formalize that with a dev profile.

The repository now becomes the source of truth:

registeredClientRepository.findByClientId("demo-client")

and:

registeredClientRepository.save(client)

Verify Flyway history

Restart the application, then enter PostgreSQL:

docker exec -it oauth2-postgres \
  psql -U postgres -d oauth2_authorization_server

Inspect:

SELECT installed_rank, version, description, success
FROM flyway_schema_history
ORDER BY installed_rank;

You should see V1–V3 applied successfully.

Verify protocol persistence

Perform a fresh Authorization Code + PKCE flow from Part 3, then inspect:

SELECT id, principal_name, authorization_grant_type, authorized_scopes
FROM oauth2_authorization;

Consent:

SELECT *
FROM oauth2_authorization_consent;

Restart Spring Boot and confirm the registered client remains.

Why JDBC for OAuth tables and JPA later for users?

Spring Authorization Server already provides JDBC repository implementations matching its protocol domain model. Using them avoids reimplementing serialization semantics for authorization/token state.

Our own user, role, scope, and audit domain models are conventional application entities, so JPA is useful there.

Using both is not an architectural contradiction:

Spring Authorization Server protocol persistence → JDBC
Application identity/admin domain              → JPA

Migration discipline

Once a migration has been applied to any shared environment, do not edit it casually.

Create a new migration:

V4__...
V5__...

This keeps schema history deterministic.

Common mistake — wiping Docker volumes

This command removes the database volume:

docker compose down -v

Use it only when you intentionally want a clean database.

Ordinary shutdown:

docker compose down

Verification checklist

[ ] PostgreSQL starts through Compose
[ ] Flyway applies V1–V3
[ ] JdbcRegisteredClientRepository is active
[ ] JdbcOAuth2AuthorizationService is active
[ ] consent service is JDBC-backed
[ ] OAuth flow still works
[ ] registered client survives application restart

The code

This part corresponds to these commits in the repository:

  • c1dd265 build: add PostgreSQL and Flyway support
  • 1c19c52 feat: persist OAuth registered clients
  • 4cd738d feat: persist OAuth authorizations
  • 56f2b73 feat: persist OAuth authorization consent

Check out the last one to see the project exactly as this part leaves it:

git checkout 56f2b73

References

Next: move authentication itself from an in-memory user into PostgreSQL users and roles.