State that survives a restart
Move registered clients, authorizations and consent into PostgreSQL, with Flyway owning the schema.
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
);
}
Migration 3 — consent
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:
c1dd265build: add PostgreSQL and Flyway support1c19c52feat: persist OAuth registered clients4cd738dfeat: persist OAuth authorizations56f2b73feat: persist OAuth authorization consent
Check out the last one to see the project exactly as this part leaves it:
git checkout 56f2b73
References
- Spring Authorization Server core model/components: https://docs.spring.io/spring-authorization-server/reference/core-model-components.html
- Flyway documentation: https://documentation.red-gate.com/fd
- PostgreSQL documentation: https://www.postgresql.org/docs/
Next: move authentication itself from an in-memory user into PostgreSQL users and roles.